From 21d04e4e67a6d48d1b5f845f6e93f8b6862c426e Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Thu, 3 Sep 2020 12:36:22 +0200 Subject: [PATCH 1/2] feat: implement resursive search algorithm Closes #77 --- apidom/package-lock.json | 4 +- apidom/packages/@types/minim.d.ts | 12 + apidom/packages/apidom/package.json | 2 + apidom/packages/apidom/src/index.ts | 12 +- apidom/packages/apidom/src/traversal.ts | 108 ++++ .../packages/apidom/test/predicates/index.ts | 574 ++++++++++++++++++ apidom/packages/apidom/test/traversal.ts | 108 ++++ 7 files changed, 817 insertions(+), 3 deletions(-) create mode 100644 apidom/packages/apidom/src/traversal.ts create mode 100644 apidom/packages/apidom/test/predicates/index.ts create mode 100644 apidom/packages/apidom/test/traversal.ts diff --git a/apidom/package-lock.json b/apidom/package-lock.json index efa556302a..ab3c639b15 100644 --- a/apidom/package-lock.json +++ b/apidom/package-lock.json @@ -3539,9 +3539,11 @@ "apidom": { "version": "file:packages/apidom", "requires": { + "apidom-ast": "file:packages/apidom-ast", "minim": "=0.23.8", "ramda": "=0.27.0", - "ramda-adjunct": "=2.27.0" + "ramda-adjunct": "=2.27.0", + "stampit": "=4.3.1" } }, "apidom-ast": { diff --git a/apidom/packages/@types/minim.d.ts b/apidom/packages/@types/minim.d.ts index c41fe57ede..6adcb1d5a6 100644 --- a/apidom/packages/@types/minim.d.ts +++ b/apidom/packages/@types/minim.d.ts @@ -17,6 +17,8 @@ declare module 'minim' { constructor(content?: Array, meta?: Meta, attributes?: Attributes); + equals(value: any): boolean; + toValue(): any; getMetaProperty(name: any, value: any): any; @@ -109,4 +111,14 @@ declare module 'minim' { set path(path: unknown); } + + export class ArraySlice { + constructor(elements?: Array); + + get length(): number; + + hasKey(value: string): boolean; + + get(index: number): T; + } } diff --git a/apidom/packages/apidom/package.json b/apidom/packages/apidom/package.json index c301d599f6..9a2e6f8449 100644 --- a/apidom/packages/apidom/package.json +++ b/apidom/packages/apidom/package.json @@ -27,6 +27,8 @@ "author": "VladimĂ­r Gorej", "license": "Apache-2.0", "dependencies": { + "apidom-ast": "file:../apidom-ast", + "stampit": "=4.3.1", "minim": "=0.23.8", "ramda": "=0.27.0", "ramda-adjunct": "=2.27.0" diff --git a/apidom/packages/apidom/src/index.ts b/apidom/packages/apidom/src/index.ts index 904aa8ab99..8535affa2b 100644 --- a/apidom/packages/apidom/src/index.ts +++ b/apidom/packages/apidom/src/index.ts @@ -1,4 +1,5 @@ import { NamespacePlugin, Element } from 'minim'; +import { isPlainObject } from 'ramda-adjunct'; import { Namespace as ApiDOMNamespace } from './namespace'; export { default as namespace, Namespace } from './namespace'; @@ -21,9 +22,16 @@ export { } from './predicates'; export { default as createPredicate } from './predicates/helpers'; -export const createNamespace = (namespacePlugin: NamespacePlugin): ApiDOMNamespace => { +export { ArraySlice } from 'minim'; +export { filter, reject, find, some } from './traversal'; + +export const createNamespace = (namespacePlugin?: NamespacePlugin): ApiDOMNamespace => { const namespace = new ApiDOMNamespace(); - namespace.use(namespacePlugin); + + if (isPlainObject(namespacePlugin)) { + namespace.use(namespacePlugin); + } + return namespace; }; diff --git a/apidom/packages/apidom/src/traversal.ts b/apidom/packages/apidom/src/traversal.ts new file mode 100644 index 0000000000..9e225cf26c --- /dev/null +++ b/apidom/packages/apidom/src/traversal.ts @@ -0,0 +1,108 @@ +import { visit, BREAK } from 'apidom-ast'; +import stampit from 'stampit'; +import { ArraySlice } from 'minim'; +import { Pred, curry, curryN, pipe, F as stubFalse, complement, pathOr } from 'ramda'; +import { isString, isNotUndefined } from 'ramda-adjunct'; + +import { + isObjectElement, + isArrayElement, + isNumberElement, + isNullElement, + isBooleanElement, + isMemberElement, + isStringElement, +} from './predicates'; + +// getNodeType :: Node -> String +const getNodeType = (element: T): string | undefined => { + /* + * We're translating every possible higher element type to primitive minim type here. + * This allows us keep key mapping to minimum. + */ + /* eslint-disable no-nested-ternary */ + return isObjectElement(element) + ? 'object' + : isArrayElement(element) + ? 'array' + : isNumberElement(element) + ? 'number' + : isNullElement(element) + ? 'null' + : isBooleanElement(element) + ? 'boolean' + : isMemberElement(element) + ? 'member' + : isStringElement(element) + ? 'string' + : undefined; + /* eslint-enable */ +}; + +// isNode :: Node -> Boolean +const isNode = curryN(1, pipe(getNodeType, isString)); + +const keyMap = { + object: ['content'], + array: ['content'], + member: ['key', 'value'], +}; + +const Visitor = stampit({ + props: { + result: [], + predicate: stubFalse, + return: undefined, + }, + init({ predicate }) { + this.result = []; + this.predicate = predicate; + }, + methods: { + enter(element) { + if (this.predicate(element)) { + this.result.push(element); + return this.return; + } + return undefined; + }, + }, +}); + +// finds all elements matching the predicate +// filter :: Pred -> Element -> ArraySlice +export const filter = curry( + (predicate: Pred, element: T): ArraySlice => { + const visitor = Visitor({ predicate }); + + // @ts-ignore + visit(element, visitor, { keyMap, nodeTypeGetter: getNodeType, nodePredicate: isNode }); + + return new ArraySlice(visitor.result); + }, +); + +// complement of filter +// reject :: Pred -> Element -> ArraySlice +export const reject = curry( + (predicate: Pred, element: T): ArraySlice => { + return filter(complement(predicate))(element); + }, +); + +// first first element in that satisfies the provided predicate +// find :: Pred -> Element -> Element | Undefined +export const find = curry((predicate: Pred, element: T): T | undefined => { + const visitor = Visitor({ predicate, return: BREAK }); + + // @ts-ignore + visit(element, visitor, { keyMap, nodeTypeGetter: getNodeType, nodePredicate: isNode }); + + return pathOr(undefined, [0], visitor.result); +}); + +// tests whether at least one element passes the predicate +// some :: Pred -> Element -> Boolean +export const some = curry((predicate: Pred, element: T): boolean => { + return isNotUndefined(find(predicate)(element)); +}); diff --git a/apidom/packages/apidom/test/predicates/index.ts b/apidom/packages/apidom/test/predicates/index.ts new file mode 100644 index 0000000000..57b0c47997 --- /dev/null +++ b/apidom/packages/apidom/test/predicates/index.ts @@ -0,0 +1,574 @@ +import { assert } from 'chai'; +import { + Element, + StringElement, + ArrayElement, + ObjectElement, + NumberElement, + NullElement, + BooleanElement, + MemberElement, + LinkElement, + RefElement, +} from 'minim'; + +import { + isElement, + isStringElement, + isNumberElement, + isNullElement, + isBooleanElement, + isArrayElement, + isObjectElement, + isMemberElement, + isLinkElement, + isRefElement, +} from '../../src'; + +describe('predicates', function () { + context('isElement', function () { + context('given Element instance value', function () { + specify('should return true', function () { + const element = new Element(); + + assert.isTrue(isElement(element)); + }); + }); + + context('given subtype instance value', function () { + specify('should return true', function () { + assert.isTrue(isElement(new StringElement())); + assert.isTrue(isElement(new ArrayElement())); + assert.isTrue(isElement(new ObjectElement())); + }); + }); + + context('given non Element instance value', function () { + specify('should return false', function () { + assert.isFalse(isElement(1)); + assert.isFalse(isElement(null)); + assert.isFalse(isElement(undefined)); + assert.isFalse(isElement({})); + assert.isFalse(isElement([])); + assert.isFalse(isElement('string')); + }); + }); + + specify('should support duck-typing', function () { + const elementDuck = { + element: undefined, + content: undefined, + primitive() { + return undefined; + }, + }; + const elementSwan = {}; + + assert.isTrue(isElement(elementDuck)); + assert.isFalse(isElement(elementSwan)); + }); + }); + + context('isStringElement', function () { + context('given StringElement instance value', function () { + specify('should return true', function () { + const element = new StringElement(); + + assert.isTrue(isStringElement(element)); + }); + }); + + context('given subtype instance value', function () { + specify('should return true', function () { + class StringSubElement extends StringElement {} + + assert.isTrue(isStringElement(new StringSubElement())); + }); + }); + + context('given non StringElement instance value', function () { + specify('should return false', function () { + assert.isFalse(isStringElement(1)); + assert.isFalse(isStringElement(null)); + assert.isFalse(isStringElement(undefined)); + assert.isFalse(isStringElement({})); + assert.isFalse(isStringElement([])); + assert.isFalse(isStringElement('string')); + + assert.isFalse(isStringElement(new ArrayElement())); + assert.isFalse(isStringElement(new ObjectElement())); + }); + }); + + specify('should support duck-typing', function () { + const stringElementDuck = { + element: 'string', + content: undefined, + primitive() { + return 'string'; + }, + get length() { + return 0; + }, + }; + + const stringElementSwan = { + element: undefined, + content: undefined, + primitive() { + return 'swan'; + }, + get length() { + return 0; + }, + }; + + assert.isTrue(isStringElement(stringElementDuck)); + assert.isFalse(isStringElement(stringElementSwan)); + }); + }); + + context('isNumberElement', function () { + context('given NumberElement instance value', function () { + specify('should return true', function () { + const element = new NumberElement(); + + assert.isTrue(isNumberElement(element)); + }); + }); + + context('given subtype instance value', function () { + specify('should return true', function () { + class NumberSubElement extends NumberElement {} + + assert.isTrue(isNumberElement(new NumberSubElement())); + }); + }); + + context('given non NumberElement instance value', function () { + specify('should return false', function () { + assert.isFalse(isNumberElement(1)); + assert.isFalse(isNumberElement(null)); + assert.isFalse(isNumberElement(undefined)); + assert.isFalse(isNumberElement({})); + assert.isFalse(isNumberElement([])); + assert.isFalse(isNumberElement('string')); + + assert.isFalse(isNumberElement(new ArrayElement())); + assert.isFalse(isNumberElement(new ObjectElement())); + }); + }); + + specify('should support duck-typing', function () { + const numberElementDuck = { + element: 'number', + content: undefined, + primitive() { + return 'number'; + }, + }; + + const numberElementSwan = { + element: undefined, + content: undefined, + primitive() { + return 'swan'; + }, + }; + + assert.isTrue(isNumberElement(numberElementDuck)); + assert.isFalse(isNumberElement(numberElementSwan)); + }); + }); + + context('isNullElement', function () { + context('given NullElement instance value', function () { + specify('should return true', function () { + const element = new NullElement(); + + assert.isTrue(isNullElement(element)); + }); + }); + + context('given subtype instance value', function () { + specify('should return true', function () { + class NullSubElement extends NullElement {} + + assert.isTrue(isNullElement(new NullSubElement())); + }); + }); + + context('given non NullElement instance value', function () { + specify('should return false', function () { + assert.isFalse(isNullElement(1)); + assert.isFalse(isNullElement(null)); + assert.isFalse(isNullElement(undefined)); + assert.isFalse(isNullElement({})); + assert.isFalse(isNullElement([])); + assert.isFalse(isNullElement('string')); + + assert.isFalse(isNullElement(new ArrayElement())); + assert.isFalse(isNullElement(new ObjectElement())); + }); + }); + + specify('should support duck-typing', function () { + const nullElementDuck = { + element: 'null', + content: undefined, + primitive() { + return 'null'; + }, + }; + + const nullElementSwan = { + element: undefined, + content: undefined, + primitive() { + return 'swan'; + }, + }; + + assert.isTrue(isNullElement(nullElementDuck)); + assert.isFalse(isNullElement(nullElementSwan)); + }); + }); + + context('isBooleanElement', function () { + context('given BooleanElement instance value', function () { + specify('should return true', function () { + const element = new BooleanElement(); + + assert.isTrue(isBooleanElement(element)); + }); + }); + + context('given subtype instance value', function () { + specify('should return true', function () { + class BooleanSubElement extends BooleanElement {} + + assert.isTrue(isBooleanElement(new BooleanSubElement())); + }); + }); + + context('given non BooleanElement instance value', function () { + specify('should return false', function () { + assert.isFalse(isBooleanElement(1)); + assert.isFalse(isBooleanElement(null)); + assert.isFalse(isBooleanElement(undefined)); + assert.isFalse(isBooleanElement({})); + assert.isFalse(isBooleanElement([])); + assert.isFalse(isBooleanElement('string')); + + assert.isFalse(isBooleanElement(new ArrayElement())); + assert.isFalse(isBooleanElement(new ObjectElement())); + }); + }); + + specify('should support duck-typing', function () { + const booleanElementDuck = { + element: 'boolean', + content: undefined, + primitive() { + return 'boolean'; + }, + }; + + const booleanElementSwan = { + element: undefined, + content: undefined, + primitive() { + return 'swan'; + }, + }; + + assert.isTrue(isBooleanElement(booleanElementDuck)); + assert.isFalse(isBooleanElement(booleanElementSwan)); + }); + }); + + context('isArrayElement', function () { + context('given ArrayElement instance value', function () { + specify('should return true', function () { + const element = new ArrayElement(); + + assert.isTrue(isArrayElement(element)); + }); + }); + + context('given subtype instance value', function () { + specify('should return true', function () { + assert.isTrue(isArrayElement(new ObjectElement())); + }); + }); + + context('given non ArrayElement instance value', function () { + specify('should return false', function () { + assert.isFalse(isArrayElement(1)); + assert.isFalse(isArrayElement(null)); + assert.isFalse(isArrayElement(undefined)); + assert.isFalse(isArrayElement({})); + assert.isFalse(isArrayElement([])); + assert.isFalse(isArrayElement('string')); + + assert.isFalse(isArrayElement(new StringElement())); + assert.isFalse(isArrayElement(new BooleanElement())); + }); + }); + + specify('should support duck-typing', function () { + const arrayElementDuck = { + element: 'array', + content: [], + primitive() { + return 'array'; + }, + push() {}, + unshift() {}, + map() {}, + reduce() {}, + }; + + const arrayElementSwan = { + element: undefined, + content: undefined, + primitive() { + return 'swan'; + }, + }; + + assert.isTrue(isArrayElement(arrayElementDuck)); + assert.isFalse(isArrayElement(arrayElementSwan)); + }); + }); + + context('isObjectElement', function () { + context('given ObjectElement instance value', function () { + specify('should return true', function () { + const element = new ObjectElement(); + + assert.isTrue(isObjectElement(element)); + }); + }); + + context('given subtype instance value', function () { + specify('should return true', function () { + class ObjectSubElement extends ObjectElement {} + + assert.isTrue(isObjectElement(new ObjectSubElement())); + }); + }); + + context('given non ObjectElement instance value', function () { + specify('should return false', function () { + assert.isFalse(isObjectElement(1)); + assert.isFalse(isObjectElement(null)); + assert.isFalse(isObjectElement(undefined)); + assert.isFalse(isObjectElement({})); + assert.isFalse(isObjectElement([])); + assert.isFalse(isObjectElement('string')); + + assert.isFalse(isObjectElement(new StringElement())); + assert.isFalse(isObjectElement(new BooleanElement())); + }); + }); + + specify('should support duck-typing', function () { + const objectElementDuck = { + element: 'object', + content: [], + primitive() { + return 'object'; + }, + keys() {}, + values() {}, + items() {}, + }; + + const objectElementSwan = { + element: undefined, + content: undefined, + primitive() { + return 'swan'; + }, + }; + + assert.isTrue(isObjectElement(objectElementDuck)); + assert.isFalse(isObjectElement(objectElementSwan)); + }); + }); + + context('isMemberElement', function () { + context('given MemberELement instance value', function () { + specify('should return true', function () { + const element = new MemberElement(); + + assert.isTrue(isMemberElement(element)); + }); + }); + + context('given subtype instance value', function () { + specify('should return true', function () { + class MemberSubElement extends MemberElement {} + + assert.isTrue(isMemberElement(new MemberSubElement())); + }); + }); + + context('given non MemberElement instance value', function () { + specify('should return false', function () { + assert.isFalse(isMemberElement(1)); + assert.isFalse(isMemberElement(null)); + assert.isFalse(isMemberElement(undefined)); + assert.isFalse(isMemberElement({})); + assert.isFalse(isMemberElement([])); + assert.isFalse(isMemberElement('string')); + + assert.isFalse(isMemberElement(new StringElement())); + assert.isFalse(isMemberElement(new BooleanElement())); + }); + }); + + specify('should support duck-typing', function () { + const memberElementDuck = { + element: 'member', + content: {}, + primitive() { + return undefined; + }, + get key() { + return 'key'; + }, + get value() { + return 'value'; + }, + }; + + const memberElementSwan = { + element: 'member', + content: {}, + primitive() { + return undefined; + }, + }; + + assert.isTrue(isMemberElement(memberElementDuck)); + assert.isFalse(isMemberElement(memberElementSwan)); + }); + }); + + context('isLinkElement', function () { + context('given LinkElement instance value', function () { + specify('should return true', function () { + const element = new LinkElement(); + + assert.isTrue(isLinkElement(element)); + }); + }); + + context('given subtype instance value', function () { + specify('should return true', function () { + class LinkSubElement extends LinkElement {} + + assert.isTrue(isLinkElement(new LinkSubElement())); + }); + }); + + context('given non LinkElement instance value', function () { + specify('should return false', function () { + assert.isFalse(isLinkElement(1)); + assert.isFalse(isLinkElement(null)); + assert.isFalse(isLinkElement(undefined)); + assert.isFalse(isLinkElement({})); + assert.isFalse(isLinkElement([])); + assert.isFalse(isLinkElement('string')); + + assert.isFalse(isLinkElement(new StringElement())); + assert.isFalse(isLinkElement(new BooleanElement())); + }); + }); + + specify('should support duck-typing', function () { + const linkElementDuck = { + element: 'link', + content: [], + primitive() { + return undefined; + }, + get href() { + return 'href'; + }, + get relation() { + return 'relation'; + }, + }; + + const linkElementSwan = { + element: 'link', + content: [], + primitive() { + return undefined; + }, + }; + + assert.isTrue(isLinkElement(linkElementDuck)); + assert.isFalse(isLinkElement(linkElementSwan)); + }); + }); + + context('isRefElement', function () { + context('given RefElement instance value', function () { + specify('should return true', function () { + const element = new RefElement(); + + assert.isTrue(isRefElement(element)); + }); + }); + + context('given subtype instance value', function () { + specify('should return true', function () { + class RefSubElement extends RefElement {} + + assert.isTrue(isRefElement(new RefSubElement())); + }); + }); + + context('given non RefElement instance value', function () { + specify('should return false', function () { + assert.isFalse(isRefElement(1)); + assert.isFalse(isRefElement(null)); + assert.isFalse(isRefElement(undefined)); + assert.isFalse(isRefElement({})); + assert.isFalse(isRefElement([])); + assert.isFalse(isRefElement('string')); + + assert.isFalse(isRefElement(new StringElement())); + assert.isFalse(isRefElement(new BooleanElement())); + }); + }); + + specify('should support duck-typing', function () { + const refElementDuck = { + element: 'ref', + content: [], + primitive() { + return undefined; + }, + get path() { + return 'href'; + }, + }; + + const refElementSwan = { + element: 'ref', + content: [], + primitive() { + return undefined; + }, + }; + + assert.isTrue(isRefElement(refElementDuck)); + assert.isFalse(isRefElement(refElementSwan)); + }); + }); +}); diff --git a/apidom/packages/apidom/test/traversal.ts b/apidom/packages/apidom/test/traversal.ts new file mode 100644 index 0000000000..e53fe6945f --- /dev/null +++ b/apidom/packages/apidom/test/traversal.ts @@ -0,0 +1,108 @@ +import { assert } from 'chai'; +import { StringElement } from 'minim'; +import { F as stubFalse } from 'ramda'; +import { + filter, + reject, + find, + some, + createNamespace, + isMemberElement, + isNumberElement, + isStringElement, + ArraySlice, +} from '../src'; + +const namespace = createNamespace(); + +describe('traversal', function () { + context('filter', function () { + context('given ObjectElement', function () { + const objElement = new namespace.elements.Object({ a: 'b', c: 'd' }); + + specify('should return ArraySlice instance', function () { + const filtered = filter(isMemberElement, objElement); + + assert.instanceOf(filtered, ArraySlice); + }); + + specify('should find content matching the predicate', function () { + const predicate = (element: unknown): boolean => + isMemberElement(element) && element.key.equals('a'); + const filtered = filter(predicate, objElement); + + assert.lengthOf(filtered, 1); + assert.isTrue(isMemberElement(filtered.get(0))); + assert.isTrue(filtered.get(0).value.equals('b')); + }); + }); + }); + + context('reject', function () { + context('given ArrayElement', function () { + const arrayElement = new namespace.elements.Array([1, 2, 3, 'a']); + + specify('should return ArraySlice instance', function () { + const filtered = reject(isNumberElement, arrayElement); + + assert.instanceOf(filtered, ArraySlice); + }); + + specify('should reject content matching the predicate', function () { + const filtered = reject(isNumberElement, arrayElement); + const stringElement: StringElement = filtered.get(1); + + assert.lengthOf(filtered, 2); + assert.strictEqual(filtered.get(0), arrayElement); + assert.isTrue(isStringElement(stringElement)); + assert.isTrue(stringElement.equals('a')); + }); + }); + }); + + context('find', function () { + context('given ObjectElement', function () { + const objElement = new namespace.elements.Object({ a: 'b', c: 'd' }); + + specify('should return first match', function () { + const predicate = (element: unknown): boolean => + isMemberElement(element) && element.key.equals('c'); + const found = find(predicate, objElement); + + assert.isTrue(isMemberElement(found)); + assert.isTrue(found.key.equals('c')); + assert.isTrue(found.value.equals('d')); + }); + + context('given no match', function () { + specify('should return undefined', function () { + const found = find(stubFalse, objElement); + + assert.isUndefined(found); + }); + }); + }); + }); + + context('some', function () { + context('given ObjectElement', function () { + const objElement = new namespace.elements.Object({ a: 'b', c: 'd' }); + + context('given match', function () { + specify('should return true', function () { + const isFound = some(isMemberElement, objElement); + + assert.isTrue(isFound); + }); + }); + + context('given no match', function () { + specify('should return false', function () { + const isFound = some(stubFalse, objElement); + + assert.isFalse(isFound); + }); + }); + }); + }); +}); From 2ab17d3e2bf2ed525e93126c9425cd8261bbc415 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Thu, 3 Sep 2020 12:45:11 +0200 Subject: [PATCH 2/2] fix: types --- apidom/packages/apidom/tsconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apidom/packages/apidom/tsconfig.json b/apidom/packages/apidom/tsconfig.json index f50bd0d49c..0576762373 100644 --- a/apidom/packages/apidom/tsconfig.json +++ b/apidom/packages/apidom/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.json", "include": [ "../@types/**/*.d.ts", - "src/**/*", - "test/**/*" + "src/**/*" +// "test/**/*" ] }