stoplightio/yaml

View on GitHub
src/getLocationForJsonPath.ts

Summary

Maintainability
A
2 hrs
Test Coverage
A
94%
import { GetLocationForJsonPath, ILocation, JsonPath, Optional } from '@stoplight/types';

import { SpecialMappingKeys } from './consts';
import { lineForPosition } from './lineForPosition';
import { Kind, YAMLMapping, YAMLNode, YamlParserResult } from './types';
import { isObject } from './utils';

export const getLocationForJsonPath: GetLocationForJsonPath<YamlParserResult<unknown>> = (
  { ast, lineMap, metadata },
  path,
  closest = false,
) => {
  const node = findNodeAtPath(ast, path, { closest, mergeKeys: metadata !== undefined && metadata.mergeKeys === true });
  if (node === void 0) return;

  return getLoc(lineMap, {
    start: getStartPosition(node, lineMap.length > 0 ? lineMap[0] : 0),
    end: getEndPosition(node),
  });
};

function getStartPosition(node: YAMLNode, offset: number): number {
  if (node.parent && node.parent.kind === Kind.MAPPING) {
    // the parent is a mapping with no value, let's default to the end of node
    if (node.parent.value === null) {
      return node.parent.endPosition;
    }

    if (node.kind !== Kind.SCALAR) {
      return node.parent.key.endPosition + 1; // offset for colon
    }
  }

  if (node.parent === null && offset - node.startPosition === 0) {
    return 0;
  }

  return node.startPosition;
}

function getEndPosition(node: YAMLNode): number {
  switch (node.kind) {
    case Kind.SEQ:
      const { items } = node;
      if (items.length !== 0) {
        const lastItem = items[items.length - 1];
        if (lastItem !== null) {
          return getEndPosition(lastItem);
        }
      }

      break;
    case Kind.MAPPING:
      if (node.value !== null) {
        return getEndPosition(node.value);
      }

      break;
    case Kind.MAP:
      if (node.value !== null && node.mappings.length !== 0) {
        return getEndPosition(node.mappings[node.mappings.length - 1]);
      }
      break;
    case Kind.SCALAR:
      // the parent is a mapping with no value, let's default to the end of node
      if (node.parent !== null && node.parent.kind === Kind.MAPPING && node.parent.value === null) {
        return node.parent.endPosition;
      }

      break;
  }

  return node.endPosition;
}

function findNodeAtPath(
  node: YAMLNode,
  path: JsonPath,
  { closest, mergeKeys }: { closest: boolean; mergeKeys: boolean },
) {
  pathLoop: for (const segment of path) {
    if (!isObject(node)) {
      return closest ? node : void 0;
    }

    switch (node.kind) {
      case Kind.MAP:
        const mappings = getMappings(node.mappings, mergeKeys);
        // we iterate from the last to first to be compliant with JSONish mode
        // in other words, iterating from the last to first guarantees we choose the last node that might override other matching nodes
        for (let i = mappings.length - 1; i >= 0; i--) {
          const item = mappings[i];
          if (item.key.value === segment) {
            if (item.value === null) {
              node = item.key;
            } else {
              node = item.value;
            }
            continue pathLoop;
          }
        }

        return closest ? node : void 0;
      case Kind.SEQ:
        for (let i = 0; i < node.items.length; i++) {
          if (i === Number(segment)) {
            const item = node.items[i];
            if (item === null) {
              break;
            }

            node = item;
            continue pathLoop;
          }
        }

        return closest ? node : void 0;
      default:
        return closest ? node : void 0;
    }
  }

  return node;
}

function getMappings(mappings: YAMLMapping[], mergeKeys: boolean): YAMLMapping[] {
  if (!mergeKeys) return mappings;

  return mappings.reduce<YAMLMapping[]>((mergedMappings, mapping) => {
    if (isObject(mapping)) {
      if (mapping.key.value === SpecialMappingKeys.MergeKey) {
        mergedMappings.push(...reduceMergeKeys(mapping.value));
      } else {
        mergedMappings.push(mapping);
      }
    }

    return mergedMappings;
  }, []);
}

function reduceMergeKeys(node: Optional<YAMLNode | null>): YAMLMapping[] {
  if (!isObject(node)) return [];

  switch (node.kind) {
    case Kind.SEQ:
      return node.items.reduceRight<YAMLMapping[]>((items, item) => {
        items.push(...reduceMergeKeys(item));
        return items;
      }, []);
    case Kind.MAP:
      return node.mappings;
    case Kind.ANCHOR_REF:
      return reduceMergeKeys(node.value);
    default:
      return [];
  }
}

const getLoc = (lineMap: number[], { start = 0, end = 0 }): ILocation => {
  const startLine = lineForPosition(start, lineMap);
  const endLine = lineForPosition(end, lineMap);

  return {
    range: {
      start: {
        line: startLine,
        character: start - (startLine === 0 ? 0 : lineMap[startLine - 1]),
      },
      end: {
        line: endLine,
        character: end - (endLine === 0 ? 0 : lineMap[endLine - 1]),
      },
    },
  };
};