diff --git a/packages/test/src/languages/generic.ts b/packages/test/src/languages/generic.ts new file mode 100644 index 0000000..890fa88 --- /dev/null +++ b/packages/test/src/languages/generic.ts @@ -0,0 +1,24 @@ +import {chain, concatenator, LanguageFactory, lastOf} from "@lionweb/core" +import {hasher} from "@lionweb/utilities" + + +const factory = new LanguageFactory( + "Generic", + "0", + chain(concatenator("-"), hasher({ encoding: "base64" })), + lastOf +) +export const genericLanguage = factory.language + +export const SomeConcept = factory.concept("SomeConcept", false) +export const AnotherConcept = factory.concept("AnotherConcept", false) + +export const SomeConcept_ref = factory.reference(SomeConcept, "ref").ofType(AnotherConcept).isOptional() +export const SomeConcept_refs = factory.reference(SomeConcept, "refs").ofType(AnotherConcept).isMultiple() +SomeConcept.havingFeatures(SomeConcept_ref, SomeConcept_refs) + +export const SomeAnnotation = factory.annotation("SomeAnnotation") +SomeAnnotation.annotates = AnotherConcept +export const SomeAnnotation_ref = factory.reference(SomeAnnotation, "ref").ofType(SomeConcept) +SomeAnnotation.havingFeatures(SomeAnnotation_ref) + diff --git a/packages/test/src/languages/tiny-ref.ts b/packages/test/src/languages/tiny-ref.ts new file mode 100644 index 0000000..bef325b --- /dev/null +++ b/packages/test/src/languages/tiny-ref.ts @@ -0,0 +1,17 @@ +import {chain, concatenator, LanguageFactory, lastOf} from "@lionweb/core" +import {hasher} from "@lionweb/utilities" + + +const factory = new LanguageFactory( + "TinyRef", + "0", + chain(concatenator("-"), hasher({ encoding: "base64" })), + lastOf +) +export const tinyRefLanguage = factory.language + +export const MyConcept = factory.concept("MyConcept", false) +export const MyConcept_singularRef = factory.reference(MyConcept, "singularRef").ofType(MyConcept) +export const MyConcept_multivaluedRef = factory.reference(MyConcept, "multivaluedRef").ofType(MyConcept).isMultiple() +MyConcept.havingFeatures(MyConcept_singularRef, MyConcept_multivaluedRef) + diff --git a/packages/test/src/m1/reference-utils.test.ts b/packages/test/src/m1/reference-utils.test.ts new file mode 100644 index 0000000..848dd94 --- /dev/null +++ b/packages/test/src/m1/reference-utils.test.ts @@ -0,0 +1,164 @@ +import {assert} from "chai" +const {deepEqual} = assert + +import {Classifier, dynamicExtractionFacade, dynamicInstantiationFacade, DynamicNode} from "@lionweb/core" +import {incomingReferences, referencesToOutOfScopeNodes, ReferenceValue, referenceValues} from "@lionweb/utilities" + +import {AnotherConcept, SomeAnnotation, SomeAnnotation_ref, SomeConcept, SomeConcept_ref} from "../languages/generic.js" +import {MyConcept, MyConcept_multivaluedRef, MyConcept_singularRef} from "../languages/tiny-ref.js" + + +/* + * These unit tests are pretty much a straight-up copy of the ones in the ReferenceUtilsTests class in the LionWeb C# implementation: + * https://github.com/LionWeb-io/lionweb-csharp/blob/main/test/LionWeb-CSharp-Test/tests/ReferenceUtilsTests.cs . + */ + +describe("reference utils", () => { + + const createNode = (id: string, classifier: Classifier): DynamicNode => ({ + id, + classifier, + settings: {}, + annotations: [] + }) + const setValue = dynamicInstantiationFacade.setFeatureValue + + it("find a reference from a feature of a concept", () => { + const targetNode = createNode("target", AnotherConcept) + const sourceNode = createNode("source", SomeConcept) + setValue(sourceNode, SomeConcept_ref, targetNode) + const scope = [sourceNode, targetNode] + + const expectedRefs = [ + new ReferenceValue(sourceNode, targetNode, SomeConcept_ref, null) + ] + deepEqual(referenceValues(scope, dynamicExtractionFacade), expectedRefs) + deepEqual(incomingReferences(targetNode, scope, dynamicExtractionFacade), expectedRefs) + }) + + it("find a reference from an annotation", () => { + const targetNode = createNode("target", SomeConcept) + const sourceNode = createNode("source", SomeAnnotation) + setValue(sourceNode, SomeAnnotation_ref, targetNode) + const sourceContainer = createNode("sourceContainer", AnotherConcept) + sourceNode.annotations.push(sourceNode) + const scope = [sourceContainer, sourceNode, targetNode] + + /* + * Note: this doesn't actually test anything new relative to the 1st test, + * because the annotation [instance] has a reference just like a concept instance has, + * and because the annotation is explicitly declared as part of the scope. + * It's more interesting to verify that a scope computed from root nodes would contain the annotation! + */ + deepEqual( + incomingReferences(targetNode, scope, dynamicExtractionFacade), + [ + new ReferenceValue(sourceNode, targetNode, SomeAnnotation_ref, null) + ] + ) + }) + + it("find a reference to itself", () => { + const node = createNode("node", MyConcept) + setValue(node, MyConcept_singularRef, node) + + deepEqual( + incomingReferences(node, [node], dynamicExtractionFacade), + [ + new ReferenceValue(node, node, MyConcept_singularRef, null) + ] + ) + }) + + it("find references in different features of the source", () => { + const targetNode = createNode("target", MyConcept) + const sourceNode = createNode("source", MyConcept) + setValue(sourceNode, MyConcept_singularRef, targetNode) + setValue(sourceNode, MyConcept_multivaluedRef, targetNode) + deepEqual(sourceNode.settings["multivaluedRef"], [targetNode]) // assert that setValue(, , ) _added_ the value + + deepEqual( + incomingReferences(targetNode, [sourceNode], dynamicExtractionFacade), + [ + new ReferenceValue(sourceNode, targetNode, MyConcept_singularRef, null), + new ReferenceValue(sourceNode, targetNode, MyConcept_multivaluedRef, 0) + ] + ) + }) + + it("find multiple references to target in a multivalued feature of the source", () => { + const targetNode = createNode("target", MyConcept) + const sourceNode = createNode("source", MyConcept) + setValue(sourceNode, MyConcept_multivaluedRef, targetNode) + setValue(sourceNode, MyConcept_multivaluedRef, targetNode) + deepEqual(sourceNode.settings["multivaluedRef"], [targetNode, targetNode]) // assert that setValue(, , ) _added_ the values + + deepEqual( + incomingReferences(targetNode, [sourceNode], dynamicExtractionFacade), + [ + new ReferenceValue(sourceNode, targetNode, MyConcept_multivaluedRef, 0), + new ReferenceValue(sourceNode, targetNode, MyConcept_multivaluedRef, 1) + ] + ) + }) + + it("find references among multiple sources and targets", () => { + const sourceNode1 = createNode("sourceNode1", MyConcept) + const sourceNode2 = createNode("sourceNode2", MyConcept) + const targetNode1 = createNode("targetNode1", MyConcept) + const targetNode2 = createNode("targetNode2", MyConcept) + setValue(sourceNode1, MyConcept_singularRef, targetNode1) + setValue(sourceNode2, MyConcept_singularRef, targetNode2) + + deepEqual( + incomingReferences([targetNode1, targetNode2], [sourceNode1, sourceNode2], dynamicExtractionFacade), + [ + new ReferenceValue(sourceNode1, targetNode1, MyConcept_singularRef, null), + new ReferenceValue(sourceNode2, targetNode2, MyConcept_singularRef, null) + ] + ) + }) + + it("have defined behavior for duplicate target nodes", () => { + const targetNode = createNode("target", AnotherConcept) + const sourceNode = createNode("source", SomeConcept) + setValue(sourceNode, SomeConcept_ref, targetNode) + const scope = [sourceNode, targetNode] + + const expectedRefs = [ + new ReferenceValue(sourceNode, targetNode, SomeConcept_ref, null) + ] + const duplicateTargetNodes = [targetNode, targetNode] + deepEqual(incomingReferences(duplicateTargetNodes, scope, dynamicExtractionFacade), expectedRefs) + deepEqual(referenceValues(scope, dynamicExtractionFacade), expectedRefs) + }) + + it("have defined behavior when duplicate nodes in scope", () => { + const targetNode = createNode("target", AnotherConcept) + const sourceNode = createNode("source", SomeConcept) + setValue(sourceNode, SomeConcept_ref, targetNode) + const scope = [sourceNode, targetNode] + + const expectedRefs = [ + new ReferenceValue(sourceNode, targetNode, SomeConcept_ref, null) + ] + const duplicateScope = [...scope, ...scope] + deepEqual(incomingReferences(targetNode, duplicateScope, dynamicExtractionFacade), expectedRefs) + deepEqual(referenceValues(duplicateScope, dynamicExtractionFacade), expectedRefs) + }) + + it("find unreachable nodes", () => { + const targetNode = createNode("target", AnotherConcept) + const sourceNode = createNode("source", SomeConcept) + setValue(sourceNode, SomeConcept_ref, targetNode) + + deepEqual( + referencesToOutOfScopeNodes([sourceNode, sourceNode], dynamicExtractionFacade), // Note: scope is duplicate + [ + new ReferenceValue(sourceNode, targetNode, SomeConcept_ref, null) + ] + ) + }) + +}) + diff --git a/packages/utilities/README.md b/packages/utilities/README.md index a2fd881..fd31073 100644 --- a/packages/utilities/README.md +++ b/packages/utilities/README.md @@ -28,6 +28,10 @@ It contains utilities on top of the `core` package, such as: * Make `withoutAnnotations` _not_ modify the original serialization chunk. * (Use the `littoral-templates` package for textualization — of M2s, so far. This is a technical change, not a functional one, except for maybe some extra whitespace.) +* Add reference utilities that all return `ReferenceValue` objects: + * `referenceValues(, )`: all references within the given scope. + * `incomingReferences(, , )`: all (unique) references coming into the given target node(s) from the search scope. + * `referencesToOutOfScopeNodes(, )` all reference targets that are not in the given scope. ### 0.6.8 diff --git a/packages/utilities/src/index.ts b/packages/utilities/src/index.ts index 0f4f2ce..6426322 100644 --- a/packages/utilities/src/index.ts +++ b/packages/utilities/src/index.ts @@ -1,5 +1,5 @@ export * from "./hashing.js" -export * from "./utils/json.js" +export * from "./m1/reference-utils.js" export * from "./m3/index.js" export * from "./serialization/index.js" -export * from "./m3/infer-languages.js" +export * from "./utils/json.js" diff --git a/packages/utilities/src/m1/reference-utils.ts b/packages/utilities/src/m1/reference-utils.ts new file mode 100644 index 0000000..5165def --- /dev/null +++ b/packages/utilities/src/m1/reference-utils.ts @@ -0,0 +1,104 @@ +import {allFeaturesOf, ExtractionFacade, Node, Reference} from "@lionweb/core" + + +/** + * Represents information about a source and target node related through a {@link Reference}. + * An index of `null` means that the reference is (at most) single-valued. + */ +export class ReferenceValue { + constructor( + public readonly sourceNode: NT, + public readonly targetNode: NT, + public readonly reference: Reference, + public readonly index: number | null + ) {} +} + + +/** + * Finds all references within the given scope, as {@link ReferenceValue reference values}. + * To search within all nodes under a collection of root nodes, + * use _child extraction_ to compute all nodes in the forest hanging off of those root nodes as scope. + * Note that any reference is found uniquely, + * i.e. the returned {@link ReferenceValue reference values} are pairwise distinct, + * even if the scope passed contains duplicate nodes. + * + * @param scope - the {@link Node nodes} that are searched for references + * @param extractionFacade - an {@link ExtractionFacade} to reflect on nodes. + * _Note_ that it's assumed that its {@link getFeatureValue} function doesn't throw. + */ +export const referenceValues = ( + scope: NT[], + extractionFacade: ExtractionFacade +): ReferenceValue[] => { + const visit = (sourceNode: NT, reference: Reference): ReferenceValue[] => { + if (reference.multiple) { + const targetNodes = extractionFacade.getFeatureValue(sourceNode, reference) as NT[] ?? [] + return targetNodes + .map((targetNode, index) => + new ReferenceValue(sourceNode, targetNode, reference, index) + ) + } + + const targetNode = extractionFacade.getFeatureValue(sourceNode, reference) as (NT | undefined) + if (targetNode !== undefined) { + return [new ReferenceValue(sourceNode, targetNode, reference, null)] + } + + return [] + } + + return [...new Set(scope)] // ~ .distinct() + .flatMap((sourceNode) => + allFeaturesOf(extractionFacade.classifierOf(sourceNode)) + .filter((feature) => feature instanceof Reference) + .map((feature) => feature as Reference) + .flatMap((reference) => visit(sourceNode, reference)) + ) +} + + +/** + * Finds all references coming into the given target node or any of the given target nodes, + * within the given scope, as {@link ReferenceValue reference values}. + * To search within all nodes under a collection of root nodes, + * use _child extraction_ to compute all nodes in the forest hanging off of those root nodes as scope. + * Note that any reference is found uniquely, + * i.e. the returned {@link ReferenceValue reference values} are pairwise distinct, + * even if the given target nodes or scope contain duplicate nodes. + * + * @param targetNodeOrNodes - one or more target {@link Node nodes} for which the incoming references are searched + * @param scope - the {@link Node nodes} that are searched for references + * @param extractionFacade - an {@link ExtractionFacade} to reflect on nodes. + * _Note_ that it's assumed that its {@link getFeatureValue} function doesn't throw. + */ +export const incomingReferences = ( + targetNodeOrNodes: NT[] | NT, + scope: NT[], + extractionFacade: ExtractionFacade +): ReferenceValue[] => { + const targetNodes = Array.isArray(targetNodeOrNodes) ? targetNodeOrNodes : [targetNodeOrNodes] + return referenceValues(scope, extractionFacade) + .filter((referenceValue) => targetNodes.indexOf(referenceValue.targetNode) > -1) +} + + +/** + * Finds all references to nodes that are not in the given scope, as {@link ReferenceValue reference values}. + * To search within all nodes under a collection of root nodes, + * use _child extraction_ to compute all nodes in the forest hanging off of those root nodes as scope. + * Note that any reference is found uniquely, + * i.e. the returned {@link ReferenceValue reference values} are pairwise distinct, + * even if the given scope contains duplicate nodes. + * + * @param scope - the {@link Node nodes} that form the scope of “reachable” nodes + * @param extractionFacade - an {@link ExtractionFacade} to reflect on nodes. + * _Note_ that it's assumed that its {@link getFeatureValue} function doesn't throw. + */ +export const referencesToOutOfScopeNodes = ( + scope: NT[], + extractionFacade: ExtractionFacade +): ReferenceValue[] => + referenceValues(scope, extractionFacade) + .filter((referenceValue) => scope.indexOf(referenceValue.targetNode) === -1) + diff --git a/packages/utilities/src/m3/index.ts b/packages/utilities/src/m3/index.ts index 7cc8d69..ec1a332 100644 --- a/packages/utilities/src/m3/index.ts +++ b/packages/utilities/src/m3/index.ts @@ -1,4 +1,5 @@ export * from "./diagrams/Mermaid-generator.js" export * from "./diagrams/PlantUML-generator.js" -export * from "./ts-generation/ts-types-generator.js" +export * from "./infer-languages.js" export * from "./textualizer.js" +export * from "./ts-generation/ts-types-generator.js"