Repository: stoplightio/spectral
Pull request #876 (2019-12-23 23:48:05)

refactor: introduce Document
**Checklist**

- [x] Tests added / updated
- [x] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No

**Additional context**

~Technically this is a breaking change, but I doubt anyone besides us actually passes `IParsedResult`, so we can consider it a fully Internal refactor that mostly addresses the issues with naming and makes certain parts of code a bit easier to reason about.~ not breaking anymore :blush:

I'm not planning to make it a part of 5.0.


philsturgeon: I am a bit concerned about releasing breaking changes in 5.1, is this necessary? Could we hold it off for a while until we have some bigger features which might warrant a 6.0? 

Or could it be made not breaking, by returning the thing we used to return too and deprecating it for now? 
P0lip: > Or could it be made not breaking, by returning the thing we used to return too and deprecating it for now?

Yeah, I can easily make it a non-breaking one and keep it like that.
The usage of that is mostly internal limited to Stoplight anyway, so users won't notice it, but I agree we should not be violating the our versioning model.
philsturgeon: Great ok!

On Mon, Dec 30, 2019 at 18:43, Marc MacLeod <notifications@github.com> wrote:

> @marbemac commented on this pull request.
>
> ---------------------------------------------------------------
>
> In [src/functions/unreferencedReusableObject.ts](https://github.com/stoplightio/spectral/pull/876#discussion_r362051001):
>
>> @@ -1,5 +1,6 @@
> +import { isObject } from 'lodash';
>
> Yeah it's not worth it - in order to make the lodash/isObject type syntax work, we'd have to use that syntax consistently everywhere, and hope that our dependencies do as well. Better to try to get tree shaking working, but even then it's not a huge difference in output bundle size.
>
> —
> You are receiving this because your review was requested.
> Reply to this email directly, [view it on GitHub](https://github.com/stoplightio/spectral/pull/876?email_source=notifications&email_token=AAAQONJVRCV2KJOP2VWXJN3Q3IXLFA5CNFSM4J6ZAPTKYY3PNVWWK3TUL52HS4DFWFIHK3DMKJSXC5LFON2FEZLWNFSXPKTDN5WW2ZLOORPWSZGOCQMQQJQ#discussion_r362051001), or [unsubscribe](https://github.com/notifications/unsubscribe-auth/AAAQONJCS7XK3MRD2S3M4KTQ3IXLFANCNFSM4J6ZAPTA).

Patch:
diff --git a/docs/guides/javascript.md b/docs/guides/javascript.md
index c6ffe3c8..bced6900 100644
--- a/docs/guides/javascript.md
+++ b/docs/guides/javascript.md
@@ -7,22 +7,18 @@ Assuming it has been installed as a Node module via NPM/Yarn, it can be used to
 ## Linting a YAML string
 
 ```js
-const { Spectral } = require('@stoplight/spectral');
-const { getLocationForJsonPath, parseWithPointers } = require("@stoplight/yaml");
+const { Spectral, Document, Parsers } = require('@stoplight/spectral');
 
-const myOpenApiDocument = parseWithPointers(`responses:
+const myOpenApiDocument = new Document(`responses:
   '200':
     description: ''
     schema:
       $ref: '#/definitions/error-response'
-`);
+`, Parsers.Yaml);
 
 const spectral = new Spectral();
 spectral
-  .run({
-    parsed: myOpenApiDocument,
-    getLocationForJsonPath,
-  })
+  .run(myOpenApiDocument)
   .then(console.log);
 ```
 
@@ -33,7 +29,7 @@ Find out how to add formats, rules and functions below.
 
 ## Linting an Object
 
-Instead of passing a string to `parseWithPointers`, you can pass in JavaScript object, with or without `$ref`'s.
+Instead of passing a string to `Document`, you can pass in JavaScript object, with or without `$ref`'s.
 
 ```js
 const { Spectral } = require('@stoplight/spectral');
diff --git a/src/cli/services/linter/linter.ts b/src/cli/services/linter/linter.ts
index c00236a0..81842560 100644
--- a/src/cli/services/linter/linter.ts
+++ b/src/cli/services/linter/linter.ts
@@ -1,6 +1,4 @@
-import { IParserResult } from '@stoplight/types';
-import { getLocationForJsonPath } from '@stoplight/yaml';
-
+import { Document } from '../../../document';
 import {
   isJSONSchema,
   isJSONSchemaDraft2019_09,
@@ -12,10 +10,10 @@ import {
   isOpenApiv3,
 } from '../../../formats';
 import { readParsable } from '../../../fs/reader';
-import { parseYaml } from '../../../parsers';
+import * as Parsers from '../../../parsers';
 import { isRuleEnabled } from '../../../runner';
 import { IRuleResult, Spectral } from '../../../spectral';
-import { FormatLookup, IParsedResult } from '../../../types';
+import { FormatLookup } from '../../../types';
 import { ILintConfig } from '../../../types/config';
 import { getRuleset, listFiles, skipRules } from './utils';
 import { getResolver } from './utils/getResolver';
@@ -74,16 +72,14 @@ export async function lint(documents: Array<number | string>, flags: ILintConfig
       console.info(`Linting ${targetUri}`);
     }
 
-    const spec: IParserResult = parseYaml(await readParsable(targetUri, { encoding: flags.encoding }));
-
-    const parsedResult: IParsedResult = {
-      source: typeof targetUri === 'number' ? '<STDIN>' : targetUri,
-      parsed: spec,
-      getLocationForJsonPath,
-    };
+    const document = new Document(
+      await readParsable(targetUri, { encoding: flags.encoding }),
+      Parsers.Yaml,
+      typeof targetUri === 'number' ? '<STDIN>' : targetUri,
+    );
 
     results.push(
-      ...(await spectral.run(parsedResult, {
+      ...(await spectral.run(document, {
         ignoreUnknownFormat: flags.ignoreUnknownFormat,
         resolve: {
           documentUri: typeof targetUri === 'number' ? void 0 : targetUri,
diff --git a/src/document.ts b/src/document.ts
new file mode 100644
index 00000000..76743253
--- /dev/null
+++ b/src/document.ts
@@ -0,0 +1,85 @@
+import { normalize } from '@stoplight/path';
+import { DeepReadonly, GetLocationForJsonPath, IParserResult, IRange, JsonPath, Optional } from '@stoplight/types';
+import { isObjectLike } from 'lodash';
+import { formatParserDiagnostics } from './errorMessages';
+import { IParser } from './parsers/types';
+import { IRuleResult } from './types';
+import { startsWithProtocol } from './utils';
+
+export interface IDocument<D = unknown> {
+  readonly source: string | null;
+  readonly diagnostics: ReadonlyArray<IRuleResult>;
+  formats?: string[] | null;
+  getRangeForJsonPath(path: JsonPath, closest?: boolean): Optional<IRange>;
+  data: D;
+}
+
+const normalizeSource = (source: Optional<string>): string | null => {
+  if (source === void 0) return null;
+  return source && !startsWithProtocol(source) ? normalize(source) : source;
+};
+
+export class Document<D = unknown, R extends IParserResult = IParserResult<D>> implements IDocument<D> {
+  protected readonly parserResult: R;
+  public readonly source: string | null;
+  public readonly diagnostics: ReadonlyArray<IRuleResult>;
+  public formats?: string[] | null;
+
+  constructor(protected readonly input: string, protected readonly parser: IParser<R>, source?: string) {
+    this.parserResult = parser.parse(input);
+    // we need to normalize the path in case path with forward slashes is given
+    this.source = normalizeSource(source);
+    this.diagnostics = formatParserDiagnostics(this.parserResult.diagnostics, this.source);
+  }
+
+  public getRangeForJsonPath(path: JsonPath, closest?: boolean): Optional<IRange> {
+    return this.parser.getLocationForJsonPath(this.parserResult, path, closest)?.range;
+  }
+
+  public static get DEFAULT_RANGE(): DeepReadonly<IRange> {
+    return {
+      start: {
+        character: 0,
+        line: 0,
+      },
+      end: {
+        character: 0,
+        line: 0,
+      },
+    };
+  }
+
+  public get data() {
+    return this.parserResult.data;
+  }
+}
+
+export class ParsedDocument<D = unknown, R extends IParsedResult = IParsedResult> implements IDocument<D> {
+  public readonly source: string | null;
+  public readonly diagnostics: ReadonlyArray<IRuleResult>;
+  public formats?: string[] | null;
+
+  constructor(protected readonly parserResult: R, source?: string) {
+    // we need to normalize the path in case path with forward slashes is given
+    this.source = normalizeSource(source);
+    this.diagnostics = formatParserDiagnostics(this.parserResult.parsed.diagnostics, this.source);
+  }
+
+  public getRangeForJsonPath(path: JsonPath, closest?: boolean): Optional<IRange> {
+    return this.parserResult.getLocationForJsonPath(this.parserResult.parsed, path, closest)?.range;
+  }
+
+  public get data() {
+    return this.parserResult.parsed.data;
+  }
+}
+
+export interface IParsedResult<R extends IParserResult = IParserResult<unknown, any, any, any>> {
+  parsed: IParserResult;
+  getLocationForJsonPath: GetLocationForJsonPath<R>;
+  source?: string;
+  formats?: string[];
+}
+
+export const isParsedResult = (obj: any): obj is IParsedResult =>
+  isObjectLike(obj?.parsed) && typeof obj.getLocationForJsonPath === 'function';
diff --git a/src/documentInventory.ts b/src/documentInventory.ts
new file mode 100644
index 00000000..289d8975
--- /dev/null
+++ b/src/documentInventory.ts
@@ -0,0 +1,139 @@
+import { extractSourceFromRef, hasRef, isLocalRef } from '@stoplight/json';
+import { Resolver } from '@stoplight/json-ref-resolver';
+import { ICache, IGraphNodeData, IUriParser } from '@stoplight/json-ref-resolver/types';
+import { extname, resolve } from '@stoplight/path';
+import { Dictionary, IParserResult, JsonPath } from '@stoplight/types';
+import { DepGraph } from 'dependency-graph';
+import { get } from 'lodash';
+import { Document, IDocument } from './document';
+
+import { formatParserDiagnostics, formatResolverErrors } from './errorMessages';
+import * as Parsers from './parsers';
+import { IParser } from './parsers/types';
+import { IResolver, IRuleResult } from './types';
+import { getClosestJsonPath, getEndRef, isAbsoluteRef, safePointerToPath, traverseObjUntilRef } from './utils';
+
+export type DocumentInventoryItem = {
+  document: IDocument;
+  path: JsonPath;
+  missingPropertyPath: JsonPath;
+};
+
+export class DocumentInventory {
+  private static readonly _cachedRemoteDocuments = new WeakMap<ICache | IResolver, Dictionary<Document>>();
+
+  public graph!: DepGraph<IGraphNodeData>;
+  public resolved: unknown;
+  public errors!: IRuleResult[];
+  public diagnostics: IRuleResult[] = [];
+
+  public readonly referencedDocuments: Dictionary<Document>;
+
+  public get source() {
+    return this.document.source;
+  }
+
+  public get unresolved() {
+    return this.document.data;
+  }
+
+  public get formats() {
+    return this.document.formats;
+  }
+
+  constructor(public readonly document: IDocument<unknown>, protected resolver: IResolver) {
+    const cacheKey = resolver instanceof Resolver ? resolver.uriCache : resolver;
+    const cachedDocuments = DocumentInventory._cachedRemoteDocuments.get(cacheKey);
+    if (cachedDocuments) {
+      this.referencedDocuments = cachedDocuments;
+    } else {
+      this.referencedDocuments = {};
+      DocumentInventory._cachedRemoteDocuments.set(cacheKey, this.referencedDocuments);
+    }
+  }
+
+  public async resolve() {
+    const resolveResult = await this.resolver.resolve(this.document.data, {
+      ...(this.document.source !== null && { baseUri: this.document.source }),
+      parseResolveResult: this.parseResolveResult,
+    });
+
+    this.graph = resolveResult.graph;
+    this.resolved = resolveResult.result;
+    this.errors = formatResolverErrors(this.document, resolveResult.errors);
+  }
+
+  public findAssociatedItemForPath(path: JsonPath): DocumentInventoryItem | null {
+    try {
+      const newPath: JsonPath = getClosestJsonPath(this.resolved, path);
+      let $ref = traverseObjUntilRef(this.unresolved, newPath);
+
+      if ($ref === null) {
+        return {
+          document: this.document,
+          path: getClosestJsonPath(this.unresolved, path),
+          missingPropertyPath: path,
+        };
+      }
+
+      const missingPropertyPath =
+        newPath.length === 0 ? [] : path.slice(path.lastIndexOf(newPath[newPath.length - 1]) + 1);
+
+      let { source } = this;
+
+      while (true) {
+        if (source === null) return null;
+
+        $ref = getEndRef(this.graph.getNodeData(source).refMap, $ref);
+
+        if ($ref === null) return null;
+
+        const scopedPath = [...safePointerToPath($ref), ...newPath];
+        let resolvedDoc;
+
+        if (isLocalRef($ref)) {
+          resolvedDoc = source === this.document.source ? this.document : this.referencedDocuments[source];
+        } else {
+          const extractedSource = extractSourceFromRef($ref)!;
+          source = isAbsoluteRef(extractedSource) ? extractedSource : resolve(source, '..', extractedSource);
+
+          resolvedDoc = source === this.document.source ? this.document : this.referencedDocuments[source];
+          const obj =
+            scopedPath.length === 0 || hasRef(resolvedDoc.data) ? resolvedDoc.data : get(resolvedDoc.data, scopedPath);
+
+          if (hasRef(obj)) {
+            $ref = obj.$ref;
+            continue;
+          }
+        }
+
+        const closestPath = getClosestJsonPath(resolvedDoc.data, scopedPath);
+        return {
+          document: resolvedDoc,
+          path: closestPath,
+          missingPropertyPath: [...closestPath, ...missingPropertyPath],
+        };
+      }
+    } catch {
+      return null;
+    }
+  }
+
+  protected parseResolveResult = async (resolveOpts: IUriParser) => {
+    const source = resolveOpts.targetAuthority.href().replace(/\/$/, '');
+    const ext = extname(source);
+
+    const content = String(resolveOpts.result);
+    const parser: IParser<IParserResult<unknown, any, any, any>> = ext === '.json' ? Parsers.Json : Parsers.Yaml;
+    const document = new Document(content, parser, source);
+
+    resolveOpts.result = document.data;
+    if (document.diagnostics.length > 0) {
+      this.diagnostics.push(...formatParserDiagnostics(document.diagnostics, document.source));
+    }
+
+    this.referencedDocuments[source] = document;
+
+    return resolveOpts;
+  };
+}
diff --git a/src/error-messages.ts b/src/errorMessages.ts
similarity index 54%
rename from src/error-messages.ts
rename to src/errorMessages.ts
index 517d404b..802a9760 100644
--- a/src/error-messages.ts
+++ b/src/errorMessages.ts
@@ -1,7 +1,7 @@
-import { DiagnosticSeverity, IDiagnostic, Segment } from '@stoplight/types';
-import { JsonPath } from '@stoplight/types/dist';
+import { IResolveError } from '@stoplight/json-ref-resolver/types';
+import { DiagnosticSeverity, IDiagnostic, JsonPath, Segment } from '@stoplight/types';
 import { uniqBy } from 'lodash';
-import { Resolved } from './resolved';
+import { Document, IDocument } from './document';
 import { IRuleResult } from './types';
 
 const toUpperCase = (word: string) => word.toUpperCase();
@@ -31,32 +31,29 @@ const getPropertyKey = (path: JsonPath | undefined): Segment | void => {
   }
 };
 
-export function formatParserDiagnostics(diagnostics: IDiagnostic[], source?: string): IRuleResult[] {
+export function formatParserDiagnostics(diagnostics: ReadonlyArray<IDiagnostic>, source: string | null): IRuleResult[] {
   return diagnostics.map(diagnostic => ({
     ...diagnostic,
     code: 'parser',
     message: getDiagnosticErrorMessage(diagnostic),
     path: diagnostic.path || [],
-    source,
+    ...(source !== null && { source }),
   }));
 }
 
-export const formatResolverErrors = (resolved: Resolved): IRuleResult[] => {
-  return uniqBy(resolved.errors, 'message').reduce<IRuleResult[]>((errors, error) => {
-    const path = [...error.path, '$ref'];
-    const location = resolved.getLocationForJsonPath(path);
-
-    if (location) {
-      errors.push({
-        code: 'invalid-ref',
-        path,
-        message: prettyPrintResolverErrorMessage(error.message),
-        severity: DiagnosticSeverity.Error,
-        range: location.range,
-        source: error.uriStack.length > 0 ? error.uriStack[error.uriStack.length - 1] : resolved.source,
-      });
-    }
-
-    return errors;
-  }, []);
+export const formatResolverErrors = (document: IDocument, diagnostics: IResolveError[]): IRuleResult[] => {
+  return uniqBy(diagnostics, 'message').map<IRuleResult>(error => {
+    const path = [...(error.path || []), '$ref'];
+    const range = document.getRangeForJsonPath(path, true) || Document.DEFAULT_RANGE;
+    const source = error.uriStack.length > 0 ? error.uriStack[error.uriStack.length - 1] : document.source;
+
+    return {
+      code: 'invalid-ref',
+      path,
+      message: prettyPrintResolverErrorMessage(error.message),
+      severity: DiagnosticSeverity.Error,
+      range,
+      ...(source !== null && { source }),
+    };
+  });
 };
diff --git a/src/formats/json-schema.ts b/src/formats/json-schema.ts
index cdb93b5b..10965643 100644
--- a/src/formats/json-schema.ts
+++ b/src/formats/json-schema.ts
@@ -1,5 +1,5 @@
+import { isObject } from 'lodash';
 import { JSONSchema } from '../types';
-import { isObject } from '../utils/isObject';
 
 const KNOWN_JSON_SCHEMA_TYPES = ['array', 'boolean', 'integer', 'null', 'number', 'object', 'string'];
 const KNOWN_JSON_SCHEMA_COMBINERS = ['allOf', 'oneOf', 'anyOf'];
diff --git a/src/formats/openapi.ts b/src/formats/openapi.ts
index 9f5c7c23..5d2e2b85 100644
--- a/src/formats/openapi.ts
+++ b/src/formats/openapi.ts
@@ -1,4 +1,4 @@
-import { isObject } from '../utils/isObject';
+import { isObject } from 'lodash';
 
 type MaybeOAS2 = Partial<{ swagger: unknown }>;
 type MaybeOAS3 = Partial<{ openapi: unknown }>;
diff --git a/src/functions/alphabetical.ts b/src/functions/alphabetical.ts
index 5c797772..566a45a3 100644
--- a/src/functions/alphabetical.ts
+++ b/src/functions/alphabetical.ts
@@ -1,5 +1,5 @@
+import { isObject } from 'lodash';
 import { IAlphaRuleOptions, IFunction, IFunctionResult } from '../types';
-import { isObject } from '../utils';
 
 const compare = (a: unknown, b: unknown): number => {
   if ((typeof a === 'number' || Number.isNaN(Number(a))) && (typeof b === 'number' || !Number.isNaN(Number(b)))) {
diff --git a/src/functions/unreferencedReusableObject.ts b/src/functions/unreferencedReusableObject.ts
index 35e213a1..25cafaed 100644
--- a/src/functions/unreferencedReusableObject.ts
+++ b/src/functions/unreferencedReusableObject.ts
@@ -1,5 +1,6 @@
+import { isObject } from 'lodash';
 import { IFunction } from '../types';
-import { isObject, safePointerToPath } from '../utils';
+import { safePointerToPath } from '../utils';
 
 export const unreferencedReusableObject: IFunction<{ reusableObjectsLocation: string }> = (
   data,
diff --git a/src/index.ts b/src/index.ts
index 6447c655..a7fd476c 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,6 @@
+import * as Parsers from './parsers';
+
 export * from './spectral';
 export * from './formats';
-export * from './parsers';
+export { Parsers };
+export { Document, ParsedDocument } from './document';
diff --git a/src/linter.ts b/src/linter.ts
index 899b1f7b..8f6eb1d3 100644
--- a/src/linter.ts
+++ b/src/linter.ts
@@ -1,6 +1,8 @@
 import { decodePointerFragment } from '@stoplight/json';
 import { get } from 'lodash';
-import { getDefaultRange, Resolved } from './resolved';
+
+import { Document } from './document';
+import { DocumentInventory } from './documentInventory';
 import { IMessageVars, message } from './rulesets/message';
 import { getDiagnosticSeverity } from './rulesets/severity';
 import { IFunction, IGivenNode, IRuleResult, IRunRule, IThen } from './types';
@@ -14,7 +16,7 @@ export const lintNode = (
   rule: IRunRule,
   then: IThen<string, any>,
   apply: IFunction,
-  resolved: Resolved,
+  inventory: DocumentInventory,
 ): IRuleResult[] => {
   const givenPath = node.path[0] === '$' ? node.path.slice(1) : node.path;
   const targetValue = node.value;
@@ -59,7 +61,7 @@ export const lintNode = (
   }
 
   if (!targets.length) {
-    // must call then at least once, with no resolved
+    // must call then at least once, with no document
     targets.push({
       path: [],
       value: undefined,
@@ -82,23 +84,24 @@ export const lintNode = (
         {
           original: node.value,
           given: node.value,
-          resolved,
+          resolved: inventory,
         },
       ) || [];
 
     results.push(
       ...targetResults.map<IRuleResult>(result => {
         const escapedJsonPath = (result.path || targetPath).map(segment => decodePointerFragment(String(segment)));
-        const parsed = resolved.getParsedForJsonPath(escapedJsonPath);
-        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 associatedItem = inventory.findAssociatedItemForPath(escapedJsonPath);
+        const path = associatedItem?.path || getClosestJsonPath(inventory.resolved, escapedJsonPath);
+        const document = associatedItem?.document || inventory.document;
+        const range = document.getRangeForJsonPath(path, true) || Document.DEFAULT_RANGE;
+        const value = path.length === 0 ? document.data : get(document.data, path);
+        const source = associatedItem?.document.source;
 
         const vars: IMessageVars = {
           property:
-            parsed?.missingPropertyPath && parsed.missingPropertyPath.length > path.length
-              ? printPath(parsed.missingPropertyPath.slice(path.length - 1), PrintStyle.Dot)
+            associatedItem?.missingPropertyPath && associatedItem.missingPropertyPath.length > path.length
+              ? printPath(associatedItem.missingPropertyPath.slice(path.length - 1), PrintStyle.Dot)
               : path.length > 0
               ? path[path.length - 1]
               : '',
@@ -116,7 +119,7 @@ export const lintNode = (
           message: (rule.message === void 0 ? rule.description ?? resultMessage : message(rule.message, vars)).trim(),
           path,
           severity: getDiagnosticSeverity(rule.severity),
-          source: parsed?.doc.source,
+          ...(source !== null && { source }),
           range,
         };
       }),
diff --git a/src/parsers/json.ts b/src/parsers/json.ts
index 03a7bb7b..bf759b44 100644
--- a/src/parsers/json.ts
+++ b/src/parsers/json.ts
@@ -1,4 +1,10 @@
-import { parseWithPointers } from '@stoplight/json';
+import { getLocationForJsonPath, JsonParserResult, parseWithPointers } from '@stoplight/json';
+import { IParser } from './types';
 
 export const parseJson = (input: string) =>
   parseWithPointers(input, { ignoreDuplicateKeys: false, preserveKeyOrder: true });
+
+export const Json: IParser<JsonParserResult<unknown>> = {
+  parse: parseJson,
+  getLocationForJsonPath,
+};
diff --git a/src/parsers/types.ts b/src/parsers/types.ts
new file mode 100644
index 00000000..4d7a1393
--- /dev/null
+++ b/src/parsers/types.ts
@@ -0,0 +1,6 @@
+import { GetLocationForJsonPath, IParserResult } from '@stoplight/types';
+
+export interface IParser<T extends IParserResult = IParserResult<unknown>> {
+  parse(input: string): T;
+  getLocationForJsonPath: GetLocationForJsonPath<T>;
+}
diff --git a/src/parsers/yaml.ts b/src/parsers/yaml.ts
index b4b579f0..765b9768 100644
--- a/src/parsers/yaml.ts
+++ b/src/parsers/yaml.ts
@@ -1,4 +1,5 @@
-import { parseWithPointers } from '@stoplight/yaml';
+import { getLocationForJsonPath, parseWithPointers, YamlParserResult } from '@stoplight/yaml';
+import { IParser } from './types';
 
 export const parseYaml = (input: string) =>
   parseWithPointers(input, {
@@ -6,3 +7,8 @@ export const parseYaml = (input: string) =>
     mergeKeys: true,
     preserveKeyOrder: true,
   });
+
+export const Yaml: IParser<YamlParserResult<unknown>> = {
+  parse: parseYaml,
+  getLocationForJsonPath,
+};
diff --git a/src/resolved.ts b/src/resolved.ts
deleted file mode 100644
index 8aeaaae9..00000000
--- a/src/resolved.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import { extractSourceFromRef, hasRef, isLocalRef } from '@stoplight/json';
-import { IGraphNodeData, IResolveError } from '@stoplight/json-ref-resolver/types';
-import { normalize, resolve } from '@stoplight/path';
-import { Dictionary, ILocation, IRange, JsonPath } from '@stoplight/types';
-import { DepGraph } from 'dependency-graph';
-import { get } from 'lodash';
-import { IParsedResult, ResolveResult } from './types';
-import { getClosestJsonPath, getEndRef, isAbsoluteRef, safePointerToPath, traverseObjUntilRef } from './utils';
-
-export const getDefaultRange = (): IRange => ({
-  start: {
-    line: 0,
-    character: 0,
-  },
-  end: {
-    line: 0,
-    character: 0,
-  },
-});
-
-export class Resolved {
-  public readonly refMap: Dictionary<string>;
-  public readonly graph: DepGraph<IGraphNodeData>;
-  public readonly resolved: unknown;
-  public readonly unresolved: unknown;
-  public readonly errors: IResolveError[];
-  public formats?: string[] | null;
-
-  public get source() {
-    return this.parsed.source ? normalize(this.parsed.source) : this.parsed.source;
-  }
-
-  constructor(
-    public readonly parsed: IParsedResult,
-    resolveResult: ResolveResult,
-    public parsedRefs: Dictionary<IParsedResult>,
-  ) {
-    this.unresolved = parsed.parsed.data;
-    this.formats = parsed.formats;
-
-    this.refMap = resolveResult.refMap;
-    this.graph = resolveResult.graph;
-    this.resolved = resolveResult.result;
-    this.errors = resolveResult.errors;
-  }
-
-  public getParsedForJsonPath(path: JsonPath) {
-    try {
-      const newPath: JsonPath = getClosestJsonPath(this.resolved, path);
-      let $ref = traverseObjUntilRef(this.unresolved, newPath);
-
-      if ($ref === null) {
-        return {
-          path: getClosestJsonPath(this.unresolved, path),
-          doc: this.parsed,
-          missingPropertyPath: path,
-        };
-      }
-
-      const missingPropertyPath =
-        newPath.length === 0 ? [] : path.slice(path.lastIndexOf(newPath[newPath.length - 1]) + 1);
-
-      let { source } = this;
-
-      while (true) {
-        if (source === void 0) return null;
-
-        $ref = getEndRef(this.graph.getNodeData(source).refMap, $ref);
-
-        if ($ref === null) return null;
-
-        const scopedPath = [...safePointerToPath($ref), ...newPath];
-        let resolvedDoc;
-
-        if (isLocalRef($ref)) {
-          resolvedDoc = source === this.parsed.source ? this.parsed : this.parsedRefs[source];
-        } else {
-          const extractedSource = extractSourceFromRef($ref)!;
-          source = isAbsoluteRef(extractedSource) ? extractedSource : resolve(source, '..', extractedSource);
-
-          resolvedDoc = source === this.parsed.source ? this.parsed : this.parsedRefs[source];
-          const { parsed } = resolvedDoc;
-
-          const obj = scopedPath.length === 0 || hasRef(parsed.data) ? parsed.data : get(parsed.data, scopedPath);
-
-          if (hasRef(obj)) {
-            $ref = obj.$ref;
-            continue;
-          }
-        }
-
-        const closestPath = getClosestJsonPath(resolvedDoc.parsed.data, scopedPath);
-        return {
-          doc: resolvedDoc,
-          path: closestPath,
-          missingPropertyPath: [...closestPath, ...missingPropertyPath],
-        };
-      }
-    } catch {
-      return null;
-    }
-  }
-
-  public getLocationForJsonPath(path: JsonPath, closest?: boolean): ILocation {
-    const parsedResult = this.getParsedForJsonPath(path);
-    if (parsedResult === null) {
-      return {
-        range: getDefaultRange(),
-      };
-    }
-
-    const location = parsedResult.doc.getLocationForJsonPath(parsedResult.doc.parsed, parsedResult.path, closest);
-
-    return {
-      ...(parsedResult.doc.source && { uri: parsedResult.doc.source }),
-      range: location?.range || getDefaultRange(),
-    };
-  }
-
-  public getValueForJsonPath(path: JsonPath): unknown {
-    const parsedResult = this.getParsedForJsonPath(path);
-    return parsedResult === null ? void 0 : get(parsedResult.doc.parsed.data, parsedResult.path);
-  }
-}
diff --git a/src/rulesets/evaluators.ts b/src/rulesets/evaluators.ts
index 99bc4414..57e5a8f5 100644
--- a/src/rulesets/evaluators.ts
+++ b/src/rulesets/evaluators.ts
@@ -1,6 +1,6 @@
 import { Optional } from '@stoplight/types';
+import { isObject } from 'lodash';
 import { IFunction, JSONSchema } from '../types';
-import { isObject } from '../utils/isObject';
 import { decorateIFunctionWithSchemaValidation } from './validation';
 
 export type CJSExport = Partial<{ exports: object | ESCJSCompatibleExport }>;
diff --git a/src/rulesets/validation.ts b/src/rulesets/validation.ts
index 06c129eb..61ddffcb 100644
--- a/src/rulesets/validation.ts
+++ b/src/rulesets/validation.ts
@@ -2,10 +2,10 @@ import { FileRule, IRulesetFile } from '../types/ruleset';
 
 import { ErrorObject } from 'ajv';
 const AJV = require('ajv');
+import { isObject } from 'lodash';
 import * as ruleSchema from '../meta/rule.schema.json';
 import * as rulesetSchema from '../meta/ruleset.schema.json';
 import { IFunction, IFunctionPaths, IFunctionValues, JSONSchema, Rule } from '../types';
-import { isObject } from '../utils';
 
 const ajv = new AJV({ allErrors: true, jsonPointers: true });
 const validate = ajv.addSchema(ruleSchema).compile(rulesetSchema);
diff --git a/src/runner.ts b/src/runner.ts
index bc9d658e..fe96b2e6 100644
--- a/src/runner.ts
+++ b/src/runner.ts
@@ -1,7 +1,7 @@
 const { JSONPath } = require('jsonpath-plus');
 
+import { DocumentInventory } from './documentInventory';
 import { lintNode } from './linter';
-import { Resolved } from './resolved';
 import { getDiagnosticSeverity } from './rulesets/severity';
 import { FunctionCollection, IGivenNode, IRule, IRuleResult, IRunRule, RunRuleCollection } from './types';
 import { hasIntersectingElement } from './utils/';
@@ -9,7 +9,7 @@ import { hasIntersectingElement } from './utils/';
 export const isRuleEnabled = (rule: IRule) => rule.severity !== void 0 && getDiagnosticSeverity(rule.severity) !== -1;
 
 export const runRules = (
-  resolved: Resolved,
+  documentInventory: DocumentInventory,
   rules: RunRuleCollection,
   functions: FunctionCollection,
 ): IRuleResult[] => {
@@ -23,8 +23,8 @@ export const runRules = (
 
     if (
       rule.formats !== void 0 &&
-      (resolved.formats === null ||
-        (resolved.formats !== void 0 && !hasIntersectingElement(rule.formats, resolved.formats)))
+      (documentInventory.formats === null ||
+        (documentInventory.formats !== void 0 && !hasIntersectingElement(rule.formats, documentInventory.formats)))
     ) {
       continue;
     }
@@ -34,7 +34,7 @@ export const runRules = (
     }
 
     try {
-      results.push(...runRule(resolved, rule, functions));
+      results.push(...runRule(documentInventory, rule, functions));
     } catch (e) {
       console.error(`Unable to run rule '${name}':\n${e}`);
     }
@@ -43,7 +43,7 @@ export const runRules = (
   return results;
 };
 
-const runRule = (resolved: Resolved, rule: IRunRule, functions: FunctionCollection): IRuleResult[] => {
+const runRule = (resolved: DocumentInventory, rule: IRunRule, functions: FunctionCollection): IRuleResult[] => {
   const target = rule.resolved === false ? resolved.unresolved : resolved.resolved;
 
   const results: IRuleResult[] = [];
@@ -87,7 +87,7 @@ const runRule = (resolved: Resolved, rule: IRunRule, functions: FunctionCollecti
 
 function lint(
   node: IGivenNode,
-  resolved: Resolved,
+  resolved: DocumentInventory,
   rule: IRunRule,
   functions: FunctionCollection,
   results: IRuleResult[],
diff --git a/src/spectral.ts b/src/spectral.ts
index bdff9044..7afae336 100644
--- a/src/spectral.ts
+++ b/src/spectral.ts
@@ -1,16 +1,14 @@
-import { getLocationForJsonPath as getLocationForJsonPathJson, JsonParserResult, safeStringify } from '@stoplight/json';
+import { safeStringify } from '@stoplight/json';
 import { Resolver } from '@stoplight/json-ref-resolver';
-import { ICache, IUriParser } from '@stoplight/json-ref-resolver/types';
-import { extname, normalize } from '@stoplight/path';
-import { DiagnosticSeverity, Dictionary, IDiagnostic, Optional } from '@stoplight/types';
-import { getLocationForJsonPath as getLocationForJsonPathYaml, YamlParserResult } from '@stoplight/yaml';
+import { DiagnosticSeverity, Dictionary } from '@stoplight/types';
+import { YamlParserResult } from '@stoplight/yaml';
 import { memoize, merge } from 'lodash';
 
 import { STATIC_ASSETS } from './assets';
-import { formatParserDiagnostics, formatResolverErrors } from './error-messages';
+import { Document, IDocument, IParsedResult, isParsedResult, ParsedDocument } from './document';
+import { DocumentInventory } from './documentInventory';
 import { functions as defaultFunctions } from './functions';
-import { parseJson, parseYaml } from './parsers';
-import { Resolved } from './resolved';
+import * as Parsers from './parsers';
 import { readRuleset } from './rulesets';
 import { compileExportedFunction } from './rulesets/evaluators';
 import { IRulesetReadOptions } from './rulesets/reader';
@@ -20,7 +18,6 @@ import {
   FormatLookup,
   FunctionCollection,
   IConstructorOpts,
-  IParsedResult,
   IResolver,
   IRuleResult,
   IRunOpts,
@@ -38,29 +35,18 @@ memoize.Cache = WeakMap;
 export * from './types';
 
 export class Spectral {
+  private readonly _resolver: IResolver;
+
   public functions: FunctionCollection = { ...defaultFunctions };
   public rules: RunRuleCollection = {};
   public formats: RegisteredFormats;
 
   private readonly _computeFingerprint: ComputeFingerprintFunc;
-  private readonly _resolver: IResolver;
-  private readonly _parsedRefs: Dictionary<IParsedResult>;
-  private static readonly _parsedCache = new WeakMap<ICache | IResolver, Dictionary<IParsedResult>>();
 
   constructor(opts?: IConstructorOpts) {
     this._computeFingerprint = memoize(opts?.computeFingerprint || defaultComputeResultFingerprint);
-    this._resolver = opts && opts.resolver ? opts.resolver : new Resolver();
+    this._resolver = opts?.resolver || new Resolver();
     this.formats = {};
-
-    const cacheKey = this._resolver instanceof Resolver ? this._resolver.uriCache : this._resolver;
-    const _parsedRefs = Spectral._parsedCache.get(cacheKey);
-    if (_parsedRefs) {
-      this._parsedRefs = _parsedRefs;
-    } else {
-      this._parsedRefs = {};
-
-      Spectral._parsedCache.set(cacheKey, this._parsedRefs);
-    }
   }
 
   public static registerStaticAssets(assets: Dictionary<string, string>) {
@@ -69,62 +55,52 @@ export class Spectral {
   }
 
   public async runWithResolved(
-    target: IParsedResult | object | string,
+    target: IParsedResult | IDocument | object | string,
     opts: IRunOpts = {},
   ): Promise<ISpectralFullResult> {
-    let results: IRuleResult[] = [];
-
-    let parsedResult: IParsedResult | IParsedResult<YamlParserResult<unknown>>;
-    if (!isParsedResult(target)) {
-      parsedResult = {
-        parsed: parseYaml(typeof target === 'string' ? target : safeStringify(target, undefined, 2)),
-        getLocationForJsonPath: getLocationForJsonPathYaml,
-        // we need to normalize the path in case path with forward slashes is given
-        source: opts.resolve?.documentUri && normalize(opts.resolve.documentUri),
-      };
-    } else {
-      parsedResult = target;
+    const document: IDocument =
+      target instanceof Document
+        ? target
+        : isParsedResult(target)
+        ? new ParsedDocument(target)
+        : new Document<unknown, YamlParserResult<unknown>>(
+            typeof target === 'string' ? target : safeStringify(target, undefined, 2),
+            Parsers.Yaml,
+            opts.resolve?.documentUri,
+          );
+
+    if (document.source === void 0 && opts.resolve?.documentUri !== void 0) {
+      (document as Omit<Document, 'source'> & { source: string }).source = opts.resolve?.documentUri;
     }
 
-    results = results.concat(formatParserDiagnostics(parsedResult.parsed.diagnostics, parsedResult.source));
-
-    const documentUri = opts.resolve && opts.resolve.documentUri;
-    const refDiagnostics: IRuleResult[] = [];
+    const inventory = new DocumentInventory(document, this._resolver);
+    await inventory.resolve();
 
-    const resolved = new Resolved(
-      parsedResult,
-      await this._resolver.resolve(parsedResult.parsed.data, {
-        baseUri: documentUri,
-        parseResolveResult: this._parseResolveResult(refDiagnostics),
-      }),
-      this._parsedRefs,
-    );
+    const validationResults: IRuleResult[] = [...inventory.diagnostics, ...document.diagnostics, ...inventory.errors];
 
-    const validationResults = [...refDiagnostics, ...results, ...formatResolverErrors(resolved)];
-
-    if (resolved.formats === void 0) {
+    if (document.formats === void 0) {
       const registeredFormats = Object.keys(this.formats);
-      const foundFormats = registeredFormats.filter(format => this.formats[format](resolved.resolved));
+      const foundFormats = registeredFormats.filter(format => this.formats[format](inventory.resolved));
       if (foundFormats.length === 0 && opts.ignoreUnknownFormat !== true) {
-        resolved.formats = null;
+        document.formats = null;
         if (registeredFormats.length > 0) {
-          validationResults.push(this._generateUnrecognizedFormatError(parsedResult));
+          validationResults.push(this._generateUnrecognizedFormatError(document));
         }
       } else {
-        resolved.formats = foundFormats;
+        document.formats = foundFormats;
       }
     }
 
     return {
-      resolved: resolved.resolved,
+      resolved: inventory.resolved,
       results: prepareResults(
-        [...validationResults, ...runRules(resolved, this.rules, this.functions)],
+        [...validationResults, ...runRules(inventory, this.rules, this.functions)],
         this._computeFingerprint,
       ),
     };
   }
 
-  public async run(target: IParsedResult | object | string, opts: IRunOpts = {}): Promise<IRuleResult[]> {
+  public async run(target: IParsedResult | Document | object | string, opts: IRunOpts = {}): Promise<IRuleResult[]> {
     return (await this.runWithResolved(target, opts)).results;
   }
 
@@ -194,60 +170,16 @@ export class Spectral {
     this.formats[format] = fn;
   }
 
-  private _parseResolveResult = (refDiagnostics: IDiagnostic[]) => async (resolveOpts: IUriParser) => {
-    const ref = resolveOpts.targetAuthority.href().replace(/\/$/, '');
-    const ext = extname(ref);
-
-    const content = String(resolveOpts.result);
-    let parsedRefResult: Optional<IParsedResult<YamlParserResult<unknown>> | IParsedResult<JsonParserResult<unknown>>>;
-    if (ext === '.yml' || ext === '.yaml') {
-      parsedRefResult = {
-        parsed: parseYaml(content),
-        source: ref,
-        getLocationForJsonPath: getLocationForJsonPathYaml,
-      };
-    } else if (ext === '.json') {
-      parsedRefResult = {
-        parsed: parseJson(content),
-        source: ref,
-        getLocationForJsonPath: getLocationForJsonPathJson,
-      };
-    }
-
-    if (parsedRefResult !== void 0) {
-      resolveOpts.result = parsedRefResult.parsed.data;
-      if (parsedRefResult.parsed.diagnostics.length > 0) {
-        refDiagnostics.push(...formatParserDiagnostics(parsedRefResult.parsed.diagnostics, parsedRefResult.source));
-      }
-
-      this._parsedRefs[ref] = parsedRefResult;
-    }
-
-    return resolveOpts;
-  };
-
-  private _generateUnrecognizedFormatError(parsedResult: IParsedResult): IRuleResult {
+  private _generateUnrecognizedFormatError(document: IDocument): IRuleResult {
     return {
-      range: parsedResult.getLocationForJsonPath(parsedResult.parsed, [], true)?.range || {
-        start: { character: 0, line: 0 },
-        end: { character: 0, line: 0 },
-      },
-
+      range: document.getRangeForJsonPath([], true) || Document.DEFAULT_RANGE,
       message: `The provided document does not match any of the registered formats [${Object.keys(this.formats).join(
         ', ',
       )}]`,
       code: 'unrecognized-format',
       severity: DiagnosticSeverity.Warning,
-      source: parsedResult.source,
+      ...(document.source !== null && { source: document.source }),
       path: [],
     };
   }
 }
-
-export const isParsedResult = (obj: any): obj is IParsedResult => {
-  if (!obj || typeof obj !== 'object') return false;
-  if (!obj.parsed || typeof obj.parsed !== 'object') return false;
-  if (!obj.getLocationForJsonPath || typeof obj.getLocationForJsonPath !== 'function') return false;
-
-  return true;
-};
diff --git a/src/types/function.ts b/src/types/function.ts
index 31585dae..48cfcb6e 100644
--- a/src/types/function.ts
+++ b/src/types/function.ts
@@ -1,5 +1,5 @@
 import { JsonPath } from '@stoplight/types';
-import { Resolved } from '../resolved';
+import { DocumentInventory } from '../documentInventory';
 
 export type IFunction<O = any> = (
   targetValue: any,
@@ -16,7 +16,7 @@ export interface IFunctionPaths {
 export interface IFunctionValues {
   original: any;
   given: any;
-  resolved: Resolved;
+  resolved: DocumentInventory;
 }
 
 export interface IFunctionResult {
diff --git a/src/types/spectral.ts b/src/types/spectral.ts
index e6e40499..2c77e239 100644
--- a/src/types/spectral.ts
+++ b/src/types/spectral.ts
@@ -1,12 +1,5 @@
 import { IResolveOpts, IResolveResult } from '@stoplight/json-ref-resolver/types';
-import {
-  DiagnosticSeverity,
-  Dictionary,
-  GetLocationForJsonPath,
-  IDiagnostic,
-  IParserResult,
-  JsonPath,
-} from '@stoplight/types';
+import { DiagnosticSeverity, Dictionary, IDiagnostic, JsonPath } from '@stoplight/types';
 import { JSONSchema4, JSONSchema6, JSONSchema7 } from 'json-schema';
 import { IFunction, IRule, Rule } from '.';
 import { ComputeFingerprintFunc } from '../utils';
@@ -56,13 +49,6 @@ export interface IGivenNode {
   value: any;
 }
 
-export interface IParsedResult<R extends IParserResult = IParserResult<unknown, any, any, any>> {
-  parsed: IParserResult;
-  getLocationForJsonPath: GetLocationForJsonPath<R>;
-  source?: string;
-  formats?: string[];
-}
-
 export type ResolveResult = Omit<IResolveResult, 'runner'>;
 
 export interface IResolver {
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 5bc65387..ee80d12a 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,7 +1,7 @@
 export * from './empty';
 export * from './hasIntersectingElement';
-export * from './isObject';
 export * from './refs';
 export * from './prepareResults';
 export * from './printPath';
 export * from './sortResults';
+export * from './startsWithProtocol';
diff --git a/src/utils/isObject.ts b/src/utils/isObject.ts
deleted file mode 100644
index d76de650..00000000
--- a/src/utils/isObject.ts
+++ /dev/null
@@ -1,1 +0,0 @@
-export const isObject = (thing: unknown): thing is object => thing !== null && typeof thing === 'object';
diff --git a/src/utils/refs.ts b/src/utils/refs.ts
index fbc0f023..36b47cc9 100644
--- a/src/utils/refs.ts
+++ b/src/utils/refs.ts
@@ -2,8 +2,9 @@ import { extractPointerFromRef, hasRef, pointerToPath } from '@stoplight/json';
 import { isAbsolute } from '@stoplight/path';
 import { Dictionary, JsonPath } from '@stoplight/types';
 import { isObject } from 'lodash';
+import { startsWithProtocol } from './startsWithProtocol';
 
-export const isAbsoluteRef = (ref: string) => isAbsolute(ref) || /^[a-z]+:\/\//i.test(ref);
+export const isAbsoluteRef = (ref: string) => isAbsolute(ref) || startsWithProtocol(ref);
 
 export const traverseObjUntilRef = (obj: unknown, path: JsonPath): string | null => {
   let piece: unknown = obj;
diff --git a/src/utils/startsWithProtocol.ts b/src/utils/startsWithProtocol.ts
new file mode 100644
index 00000000..0016a3aa
--- /dev/null
+++ b/src/utils/startsWithProtocol.ts
@@ -0,0 +1,3 @@
+const PROTOCOL_REGEX = /^[a-z]+:\/\//i;
+
+export const startsWithProtocol = (input: string) => PROTOCOL_REGEX.test(input);


Test patch:
diff --git a/src/__tests__/document.test.ts b/src/__tests__/document.test.ts
new file mode 100644
index 00000000..5aaa14c7
--- /dev/null
+++ b/src/__tests__/document.test.ts
@@ -0,0 +1,43 @@
+import { IParsedResult, isParsedResult } from '../document';
+
+describe('isParsedResult util', () => {
+  test('correctly identifies objects that fulfill the IParsedResult interface', () => {
+    // @ts-ignore
+    expect(isParsedResult()).toBe(false);
+
+    expect(isParsedResult('')).toBe(false);
+    expect(isParsedResult([])).toBe(false);
+    expect(isParsedResult({})).toBe(false);
+    expect(
+      isParsedResult({
+        parsed: undefined,
+      }),
+    ).toBe(false);
+
+    expect(
+      isParsedResult({
+        parsed: [],
+      }),
+    ).toBe(false);
+
+    expect(
+      isParsedResult({
+        parsed: {
+          data: {},
+        },
+      }),
+    ).toBe(false);
+
+    const obj: IParsedResult = {
+      getLocationForJsonPath: jest.fn(),
+      parsed: {
+        data: {},
+        ast: {},
+        lineMap: [],
+        diagnostics: [],
+      },
+    };
+
+    expect(isParsedResult(obj)).toBe(true);
+  });
+});
diff --git a/src/__tests__/error-messages.test.ts b/src/__tests__/errorMessages.test.ts
similarity index 94%
rename from src/__tests__/error-messages.test.ts
rename to src/__tests__/errorMessages.test.ts
index 45be9bb5..e576c5f4 100644
--- a/src/__tests__/error-messages.test.ts
+++ b/src/__tests__/errorMessages.test.ts
@@ -1,5 +1,5 @@
 import { cloneDeep } from 'lodash';
-import { formatParserDiagnostics } from '../error-messages';
+import { formatParserDiagnostics } from '../errorMessages';
 
 describe('Error messages', () => {
   describe('parser diagnostics', () => {
@@ -68,7 +68,7 @@ describe('Error messages', () => {
         },
       ];
 
-      expect(formatParserDiagnostics(cloneDeep(diagnostics))).toEqual([
+      expect(formatParserDiagnostics(cloneDeep(diagnostics), null)).toEqual([
         {
           ...diagnostics[0],
           path: ['test'],
@@ -145,7 +145,7 @@ describe('Error messages', () => {
         },
       ];
 
-      expect(formatParserDiagnostics(cloneDeep(diagnostics))).toEqual([
+      expect(formatParserDiagnostics(cloneDeep(diagnostics), null)).toEqual([
         {
           ...diagnostics[0],
           path: [],
diff --git a/src/__tests__/linter.test.ts b/src/__tests__/linter.test.ts
index d3111c51..b904492c 100644
--- a/src/__tests__/linter.test.ts
+++ b/src/__tests__/linter.test.ts
@@ -1,4 +1,3 @@
-import { getLocationForJsonPath, parseWithPointers } from '@stoplight/json';
 import { Resolver } from '@stoplight/json-ref-resolver';
 import { DiagnosticSeverity } from '@stoplight/types';
 import { parse } from '@stoplight/yaml';
@@ -705,13 +704,7 @@ responses:: !!foo
 
   describe('reports duplicated properties for', () => {
     test('JSON format', async () => {
-      const result = await spectral.run(
-        {
-          parsed: parseWithPointers('{"foo":true,"foo":false}', { ignoreDuplicateKeys: false }),
-          getLocationForJsonPath,
-        },
-        { ignoreUnknownFormat: true },
-      );
+      const result = await spectral.run('{"foo":true,"foo":false}', { ignoreUnknownFormat: true });
 
       expect(result).toEqual([
         {
diff --git a/src/__tests__/spectral.jest.test.ts b/src/__tests__/spectral.jest.test.ts
index 2398c2a4..e1a10b7b 100644
--- a/src/__tests__/spectral.jest.test.ts
+++ b/src/__tests__/spectral.jest.test.ts
@@ -1,9 +1,12 @@
-import { getLocationForJsonPath, parseWithPointers } from '@stoplight/json';
+import { normalize } from '@stoplight/path';
 import { DiagnosticSeverity, Dictionary } from '@stoplight/types';
 import * as fs from 'fs';
 import * as nock from 'nock';
 import * as path from 'path';
+
+import { Document } from '../document';
 import { isOpenApiv2 } from '../formats';
+import * as Parsers from '../parsers';
 import { httpAndFileResolver } from '../resolvers/http-and-file';
 import { IRunRule, Spectral } from '../spectral';
 
@@ -98,21 +101,13 @@ describe('Spectral', () => {
   });
 
   test('should report issues for correct files with correct ranges and paths', async () => {
-    const documentUri = path.join(__dirname, './__fixtures__/document-with-external-refs.oas2.json');
+    const documentUri = normalize(path.join(__dirname, './__fixtures__/document-with-external-refs.oas2.json'));
     const spectral = new Spectral({ resolver: httpAndFileResolver });
     await spectral.loadRuleset('spectral:oas');
     spectral.registerFormat('oas2', isOpenApiv2);
-    const parsed = {
-      parsed: parseWithPointers(fs.readFileSync(documentUri, 'utf8')),
-      getLocationForJsonPath,
-      source: documentUri,
-    };
+    const document = new Document(fs.readFileSync(documentUri, 'utf8'), Parsers.Json, documentUri);
 
-    const results = await spectral.run(parsed, {
-      resolve: {
-        documentUri,
-      },
-    });
+    const results = await spectral.run(document);
 
     expect(results).toEqual(
       expect.arrayContaining([
diff --git a/src/__tests__/spectral.test.ts b/src/__tests__/spectral.test.ts
index c96f3190..8fe27ba1 100644
--- a/src/__tests__/spectral.test.ts
+++ b/src/__tests__/spectral.test.ts
@@ -1,11 +1,12 @@
-import { getLocationForJsonPath, parseWithPointers } from '@stoplight/json';
 import { IGraphNodeData } from '@stoplight/json-ref-resolver/types';
 import { DiagnosticSeverity, Dictionary } from '@stoplight/types';
 import { DepGraph } from 'dependency-graph';
-import { isParsedResult, Spectral } from '../spectral';
-import { IParsedResult, IResolver, IRunRule, RuleFunction } from '../types';
+import { merge } from 'lodash';
 
-const merge = require('lodash/merge');
+import { Document } from '../document';
+import * as Parsers from '../parsers';
+import { Spectral } from '../spectral';
+import { IResolver, IRunRule, RuleFunction } from '../types';
 
 const oasRuleset = JSON.parse(JSON.stringify(require('../rulesets/oas/index.json')));
 const oasRulesetRules: Dictionary<IRunRule, string> = oasRuleset.rules;
@@ -175,11 +176,7 @@ describe('spectral', () => {
           },
         });
 
-        const target: IParsedResult = {
-          parsed: parseWithPointers(`{"foo":"bar"}`),
-          getLocationForJsonPath,
-          source: 'foo',
-        };
+        const target = new Document(`{"foo":"bar"}`, Parsers.Json, 'foo');
 
         return expect(s.run(target)).resolves.toStrictEqual([
           {
@@ -206,39 +203,37 @@ describe('spectral', () => {
         const s = new Spectral();
         const source = 'foo.yaml';
 
-        const parsedResult: IParsedResult = {
-          getLocationForJsonPath,
-          source,
-          parsed: parseWithPointers(
-            JSON.stringify(
-              {
-                paths: {
-                  '/agreements': {
-                    get: {
-                      description: 'Get some Agreements',
-                      responses: {
-                        '200': {
-                          $ref: '#/responses/GetAgreementsOk',
-                        },
-                        default: {},
+        const document = new Document(
+          JSON.stringify(
+            {
+              paths: {
+                '/agreements': {
+                  get: {
+                    description: 'Get some Agreements',
+                    responses: {
+                      '200': {
+                        $ref: '#/responses/GetAgreementsOk',
                       },
-                      summary: 'List agreements',
-                      tags: ['agreements', 'pagination'],
+                      default: {},
                     },
+                    summary: 'List agreements',
+                    tags: ['agreements', 'pagination'],
                   },
                 },
-                responses: {
-                  GetAgreementsOk: {
-                    description: 'Successful operation',
-                    headers: {},
-                  },
+              },
+              responses: {
+                GetAgreementsOk: {
+                  description: 'Successful operation',
+                  headers: {},
                 },
               },
-              null,
-              2,
-            ),
+            },
+            null,
+            2,
           ),
-        };
+          Parsers.Json,
+          source,
+        );
 
         s.setRules({
           'pagination-responses-have-x-next-token': {
@@ -250,7 +245,7 @@ describe('spectral', () => {
           },
         });
 
-        return expect(s.run(parsedResult, { resolve: { documentUri: source } })).resolves.toEqual([
+        return expect(s.run(document)).resolves.toEqual([
           {
             code: 'pagination-responses-have-x-next-token',
             message: 'All collection endpoints have the X-Next-Token parameter in responses',
@@ -263,43 +258,4 @@ describe('spectral', () => {
       });
     });
   });
-
-  test('isParsedResult correctly identifies objects that fulfill the IParsedResult interface', () => {
-    // @ts-ignore
-    expect(isParsedResult()).toBe(false);
-
-    expect(isParsedResult('')).toBe(false);
-    expect(isParsedResult([])).toBe(false);
-    expect(isParsedResult({})).toBe(false);
-    expect(
-      isParsedResult({
-        parsed: undefined,
-      }),
-    ).toBe(false);
-
-    expect(
-      isParsedResult({
-        parsed: [],
-      }),
-    ).toBe(false);
-
-    expect(
-      isParsedResult({
-        parsed: {
-          data: {},
-        },
-      }),
-    ).toBe(false);
-
-    const obj: IParsedResult = {
-      getLocationForJsonPath: jest.fn(),
-      parsed: {
-        data: {},
-        ast: {},
-        lineMap: [],
-        diagnostics: [],
-      },
-    };
-    expect(isParsedResult(obj)).toBe(true);
-  });
 });
diff --git a/src/rulesets/oas/__tests__/oas2-unused-definition.jest.test.ts b/src/rulesets/oas/__tests__/oas2-unused-definition.jest.test.ts
index 0ec4e5d4..ca37ca3a 100644
--- a/src/rulesets/oas/__tests__/oas2-unused-definition.jest.test.ts
+++ b/src/rulesets/oas/__tests__/oas2-unused-definition.jest.test.ts
@@ -1,13 +1,14 @@
-import { getLocationForJsonPath, parseWithPointers } from '@stoplight/json';
 import * as path from '@stoplight/path';
-import { unreferencedReusableObject } from '../../../functions/unreferencedReusableObject';
-import { IParsedResult, RuleType, Spectral } from '../../../index';
-import { httpAndFileResolver } from '../../../resolvers/http-and-file';
-import { rules } from '../index.json';
-
 import { DiagnosticSeverity } from '@stoplight/types';
 import * as nock from 'nock';
+
+import { Document } from '../../../document';
 import { readParsable } from '../../../fs/reader';
+import { unreferencedReusableObject } from '../../../functions/unreferencedReusableObject';
+import { RuleType, Spectral } from '../../../index';
+import * as Parsers from '../../../parsers';
+import { httpAndFileResolver } from '../../../resolvers/http-and-file';
+import { rules } from '../index.json';
 
 describe('unusedDefinition - Http and fs remote references', () => {
   const s = new Spectral({ resolver: httpAndFileResolver });
@@ -80,10 +81,7 @@ describe('unusedDefinition - Http and fs remote references', () => {
       }
     }`;
 
-      const results = await s.run({
-        parsed: parseWithPointers(doc),
-        getLocationForJsonPath,
-      });
+      const results = await s.run(new Document(doc, Parsers.Json));
 
       expect(results).toEqual([
         {
@@ -110,19 +108,8 @@ describe('unusedDefinition - Http and fs remote references', () => {
     test('when analyzing a directly self-referencing document from the filesystem', async () => {
       const fixturePath = path.join(__dirname, '../../__tests__/__fixtures__/unusedDefinition.remoteLocal.json');
 
-      const spec = parseWithPointers(await readParsable(fixturePath, { encoding: 'utf8' }));
-
-      const parsedResult: IParsedResult = {
-        source: fixturePath,
-        parsed: spec,
-        getLocationForJsonPath,
-      };
-
-      const results = await s.run(parsedResult, {
-        resolve: {
-          documentUri: fixturePath,
-        },
-      });
+      const doc = await readParsable(fixturePath, { encoding: 'utf8' });
+      const results = await s.run(new Document(doc, Parsers.Json, fixturePath));
 
       expect(results).toEqual([]);
     });
@@ -130,19 +117,8 @@ describe('unusedDefinition - Http and fs remote references', () => {
     test('when analyzing an indirectly self-referencing document from the filesystem', async () => {
       const fixturePath = path.join(__dirname, '../../__tests__/__fixtures__/unusedDefinition.indirect.1.json');
 
-      const spec = parseWithPointers(await readParsable(fixturePath, { encoding: 'utf8' }));
-
-      const parsedResult: IParsedResult = {
-        source: fixturePath,
-        parsed: spec,
-        getLocationForJsonPath,
-      };
-
-      const results = await s.run(parsedResult, {
-        resolve: {
-          documentUri: fixturePath,
-        },
-      });
+      const doc = await readParsable(fixturePath, { encoding: 'utf8' });
+      const results = await s.run(new Document(doc, Parsers.Json, fixturePath));
 
       expect(results).toEqual([
         {
diff --git a/src/rulesets/oas/__tests__/oas2-unused-definition.karma.test.ts b/src/rulesets/oas/__tests__/oas2-unused-definition.karma.test.ts
index c56dc1c5..7d22b51d 100644
--- a/src/rulesets/oas/__tests__/oas2-unused-definition.karma.test.ts
+++ b/src/rulesets/oas/__tests__/oas2-unused-definition.karma.test.ts
@@ -1,8 +1,9 @@
-import { getLocationForJsonPath, parseWithPointers } from '@stoplight/json';
 import { DiagnosticSeverity } from '@stoplight/types';
 import { FetchMockSandbox } from 'fetch-mock';
+import { Document } from '../../../document';
 import { unreferencedReusableObject } from '../../../functions/unreferencedReusableObject';
 import { RuleType, Spectral } from '../../../index';
+import * as Parsers from '../../../parsers';
 import { httpAndFileResolver } from '../../../resolvers/http-and-file';
 import { rules } from '../index.json';
 
@@ -77,10 +78,7 @@ describe('unusedDefinition - Http remote references', () => {
       }
     }`;
 
-    const results = await s.run({
-      parsed: parseWithPointers(doc),
-      getLocationForJsonPath,
-    });
+    const results = await s.run(new Document(doc, Parsers.Json));
 
     expect(results).toEqual([
       {
diff --git a/src/rulesets/oas/__tests__/oas2-unused-definition.test.ts b/src/rulesets/oas/__tests__/oas2-unused-definition.test.ts
index 46c1c526..ee702827 100644
--- a/src/rulesets/oas/__tests__/oas2-unused-definition.test.ts
+++ b/src/rulesets/oas/__tests__/oas2-unused-definition.test.ts
@@ -1,7 +1,8 @@
-import { getLocationForJsonPath, parseWithPointers } from '@stoplight/json';
 import { DiagnosticSeverity } from '@stoplight/types';
+import { Document } from '../../../document';
 import { unreferencedReusableObject } from '../../../functions/unreferencedReusableObject';
 import { RuleType, Spectral } from '../../../index';
+import * as Parsers from '../../../parsers';
 import { httpAndFileResolver } from '../../../resolvers/http-and-file';
 import { rules } from '../index.json';
 
@@ -97,10 +98,7 @@ describe('oas2-unused-definition - local references', () => {
       }
     }`;
 
-    const results = await s.run({
-      parsed: parseWithPointers(doc),
-      getLocationForJsonPath,
-    });
+    const results = await s.run(new Document(doc, Parsers.Json));
 
     expect(results).toEqual([]);
   });
@@ -120,10 +118,7 @@ describe('oas2-unused-definition - local references', () => {
       }
     }`;
 
-    const results = await s.run({
-      parsed: parseWithPointers(doc),
-      getLocationForJsonPath,
-    });
+    const results = await s.run(new Document(doc, Parsers.Json));
 
     expect(results).toEqual([
       {
diff --git a/src/rulesets/oas/__tests__/oas3-unused-components-schema.jest.test.ts b/src/rulesets/oas/__tests__/oas3-unused-components-schema.jest.test.ts
index 406d7c2f..311254dc 100644
--- a/src/rulesets/oas/__tests__/oas3-unused-components-schema.jest.test.ts
+++ b/src/rulesets/oas/__tests__/oas3-unused-components-schema.jest.test.ts
@@ -1,11 +1,12 @@
-import { getLocationForJsonPath, parseWithPointers } from '@stoplight/json';
 import * as path from '@stoplight/path';
 import { DiagnosticSeverity } from '@stoplight/types';
 import * as nock from 'nock';
 
+import { Document } from '../../../document';
 import { readParsable } from '../../../fs/reader';
 import { unreferencedReusableObject } from '../../../functions/unreferencedReusableObject';
-import { IParsedResult, RuleType, Spectral } from '../../../index';
+import { RuleType, Spectral } from '../../../index';
+import * as Parsers from '../../../parsers';
 import { httpAndFileResolver } from '../../../resolvers/http-and-file';
 import { rules } from '../index.json';
 
@@ -84,10 +85,7 @@ describe('unusedComponentsSchema - Http and fs remote references', () => {
       }
     }`;
 
-      const results = await s.run({
-        parsed: parseWithPointers(doc),
-        getLocationForJsonPath,
-      });
+      const results = await s.run(new Document(doc, Parsers.Json));
 
       expect(results).toEqual([
         {
@@ -114,19 +112,8 @@ describe('unusedComponentsSchema - Http and fs remote references', () => {
     test('when analyzing a directly self-referencing document from the filesystem', async () => {
       const fixturePath = path.join(__dirname, '../../__tests__/__fixtures__/unusedComponentsSchema.remoteLocal.json');
 
-      const spec = parseWithPointers(await readParsable(fixturePath, { encoding: 'utf8' }));
-
-      const parsedResult: IParsedResult = {
-        source: fixturePath,
-        parsed: spec,
-        getLocationForJsonPath,
-      };
-
-      const results = await s.run(parsedResult, {
-        resolve: {
-          documentUri: fixturePath,
-        },
-      });
+      const spec = await readParsable(fixturePath, { encoding: 'utf8' });
+      const results = await s.run(new Document(spec, Parsers.Json, fixturePath));
 
       expect(results).toEqual([]);
     });
@@ -134,19 +121,8 @@ describe('unusedComponentsSchema - Http and fs remote references', () => {
     test('when analyzing an indirectly self-referencing document from the filesystem', async () => {
       const fixturePath = path.join(__dirname, '../../__tests__/__fixtures__/unusedComponentsSchema.indirect.1.json');
 
-      const spec = parseWithPointers(await readParsable(fixturePath, { encoding: 'utf8' }));
-
-      const parsedResult: IParsedResult = {
-        source: fixturePath,
-        parsed: spec,
-        getLocationForJsonPath,
-      };
-
-      const results = await s.run(parsedResult, {
-        resolve: {
-          documentUri: fixturePath,
-        },
-      });
+      const spec = await readParsable(fixturePath, { encoding: 'utf8' });
+      const results = await s.run(new Document(spec, Parsers.Json, fixturePath));
 
       expect(results).toEqual([
         {
diff --git a/src/rulesets/oas/__tests__/oas3-unused-components-schema.karma.test.ts b/src/rulesets/oas/__tests__/oas3-unused-components-schema.karma.test.ts
index ec005dc9..e7a4ac91 100644
--- a/src/rulesets/oas/__tests__/oas3-unused-components-schema.karma.test.ts
+++ b/src/rulesets/oas/__tests__/oas3-unused-components-schema.karma.test.ts
@@ -1,8 +1,9 @@
-import { getLocationForJsonPath, parseWithPointers } from '@stoplight/json';
 import { DiagnosticSeverity } from '@stoplight/types';
 import { FetchMockSandbox } from 'fetch-mock';
+import { Document } from '../../../document';
 import { unreferencedReusableObject } from '../../../functions/unreferencedReusableObject';
 import { RuleType, Spectral } from '../../../index';
+import * as Parsers from '../../../parsers';
 import { httpAndFileResolver } from '../../../resolvers/http-and-file';
 import { rules } from '../index.json';
 
@@ -81,10 +82,7 @@ describe('unusedComponentsSchema - Http remote references', () => {
       }
     }`;
 
-    const results = await s.run({
-      parsed: parseWithPointers(doc),
-      getLocationForJsonPath,
-    });
+    const results = await s.run(new Document(doc, Parsers.Json));
 
     expect(results).toEqual([
       {
diff --git a/src/rulesets/oas/__tests__/oas3-unused-components-schema.test.ts b/src/rulesets/oas/__tests__/oas3-unused-components-schema.test.ts
index c3fb7b90..0351d33f 100644
--- a/src/rulesets/oas/__tests__/oas3-unused-components-schema.test.ts
+++ b/src/rulesets/oas/__tests__/oas3-unused-components-schema.test.ts
@@ -1,7 +1,8 @@
-import { getLocationForJsonPath, parseWithPointers } from '@stoplight/json';
 import { DiagnosticSeverity } from '@stoplight/types';
+import { Document } from '../../../document';
 import { unreferencedReusableObject } from '../../../functions/unreferencedReusableObject';
 import { RuleType, Spectral } from '../../../index';
+import * as Parsers from '../../../parsers';
 import { httpAndFileResolver } from '../../../resolvers/http-and-file';
 import { rules } from '../index.json';
 
@@ -101,10 +102,7 @@ describe('unusedComponentsSchema - Local references', () => {
       }
     }`;
 
-    const results = await s.run({
-      parsed: parseWithPointers(doc),
-      getLocationForJsonPath,
-    });
+    const results = await s.run(new Document(doc, Parsers.Json));
 
     expect(results).toEqual([]);
   });
@@ -126,10 +124,7 @@ describe('unusedComponentsSchema - Local references', () => {
       }
     }`;
 
-    const results = await s.run({
-      parsed: parseWithPointers(doc),
-      getLocationForJsonPath,
-    });
+    const results = await s.run(new Document(doc, Parsers.Json));
 
     expect(results).toEqual([
       {
diff --git a/src/rulesets/oas/functions/__tests__/refSiblings.test.ts b/src/rulesets/oas/functions/__tests__/refSiblings.test.ts
index c98dfe57..d414b3a0 100644
--- a/src/rulesets/oas/functions/__tests__/refSiblings.test.ts
+++ b/src/rulesets/oas/functions/__tests__/refSiblings.test.ts
@@ -1,6 +1,7 @@
-import { getLocationForJsonPath, parseWithPointers } from '@stoplight/json';
 import { DiagnosticSeverity } from '@stoplight/types';
+import { Document } from '../../../../document';
 import { RuleType, Spectral } from '../../../../index';
+import * as Parsers from '../../../../parsers';
 import { rules as oasRules } from '../../../oas/index.json';
 import refSiblings from '../refSiblings';
 
@@ -60,10 +61,7 @@ describe('refSiblings', () => {
   }
 }`;
 
-    const results = await s.run({
-      parsed: parseWithPointers(doc),
-      getLocationForJsonPath,
-    });
+    const results = await s.run(new Document(doc, Parsers.Json));
 
     expect(results).toEqual([
       {
@@ -175,10 +173,7 @@ describe('refSiblings', () => {
   }
 }`;
 
-    const results = await s.run({
-      parsed: parseWithPointers(doc),
-      getLocationForJsonPath,
-    });
+    const results = await s.run(new Document(doc, Parsers.Json));
 
     expect(results).toEqual([
       {