diff --git a/ECoreNetto.Tests/Resource/PositionalAndIdFragmentTestFixture.cs b/ECoreNetto.Tests/Resource/PositionalAndIdFragmentTestFixture.cs new file mode 100644 index 0000000..2948c88 --- /dev/null +++ b/ECoreNetto.Tests/Resource/PositionalAndIdFragmentTestFixture.cs @@ -0,0 +1,263 @@ +// ------------------------------------------------------------------------------------------------ +// +// +// Copyright 2017-2026 Starion Group S.A. +// SPDX-License-Identifier: Apache-2.0 +// +// +// ------------------------------------------------------------------------------------------------ + +namespace ECoreNetto.Tests.Resource +{ + using System; + using System.IO; + using System.Linq; + + using ECoreNetto.Resource; + + using NUnit.Framework; + + /// + /// Suite of tests for the EMF URI-fragment-resolution items of issue #106: positional + /// //@feature.index fragments (resolved by structural navigation) and bare xmi:id + /// fragments (resolved through an id-to-object map), both same-file and cross-file. + /// + [TestFixture] + public class PositionalAndIdFragmentTestFixture + { + private ResourceSet resourceSet = null!; + + [SetUp] + public void SetUp() + { + this.resourceSet = new ResourceSet(); + } + + [Test] + public void Verify_that_same_file_positional_fragments_resolve() + { + var root = this.Load("positional.ecore", PositionalModel); + + var alpha = Class(root, "Alpha"); + var beta = Class(root, "Beta"); + var text = root.EClassifiers.OfType().Single(c => c.Name == "Text"); + + var toBeta = alpha.EStructuralFeatures.OfType().Single(r => r.Name == "toBeta"); + var label = beta.EStructuralFeatures.OfType().Single(a => a.Name == "label"); + + Assert.Multiple(() => + { + // eType="#//@eClassifiers.1" -> the second classifier, Beta + Assert.That(toBeta.EType, Is.SameAs(beta)); + + // eType="#//@eClassifiers.2" -> the third classifier, the Text data type + Assert.That(label.EType, Is.SameAs(text)); + }); + } + + [Test] + public void Verify_that_a_deep_positional_fragment_resolves_to_a_structural_feature() + { + var root = this.Load("positional.ecore", PositionalModel); + var resource = this.resourceSet.Resources.Single(); + + var toBeta = Class(root, "Alpha").EStructuralFeatures.Single(f => f.Name == "toBeta"); + + var resolved = resource.GetEObject("positional.ecore#//@eClassifiers.0/@eStructuralFeatures.0"); + + Assert.That(resolved, Is.SameAs(toBeta)); + } + + [Test] + public void Verify_that_out_of_range_and_unknown_positional_fragments_do_not_resolve() + { + this.Load("positional.ecore", PositionalModel); + var resource = this.resourceSet.Resources.Single(); + + Assert.Multiple(() => + { + // index beyond the number of classifiers + Assert.That(resource.GetEObject("positional.ecore#//@eClassifiers.99"), Is.Null); + + // a containment feature that Alpha does not have + Assert.That(resource.GetEObject("positional.ecore#//@eClassifiers.0/@eOperations.0"), Is.Null); + + // an unknown containment feature name + Assert.That(resource.GetEObject("positional.ecore#//@eClassifiers.0/@bogus.0"), Is.Null); + + // a navigation segment that is not of the '@feature.index' form + Assert.That(resource.GetEObject("positional.ecore#//@eClassifiers.0/plain"), Is.Null); + + // an '@feature' segment without an index + Assert.That(resource.GetEObject("positional.ecore#//@eClassifiers"), Is.Null); + + // an '@feature.index' segment with a non-numeric index + Assert.That(resource.GetEObject("positional.ecore#//@eClassifiers.x"), Is.Null); + + // a name-based path that does not exist (structural navigation is only for '@' segments) + Assert.That(resource.GetEObject("positional.ecore#//NoSuchClass"), Is.Null); + + // an empty in-document fragment + Assert.That(resource.GetEObject("positional.ecore#"), Is.Null); + + // an unknown xmi:id + Assert.That(resource.GetEObject("#_missing"), Is.Null); + }); + } + + [Test] + public void Verify_that_positional_fragments_navigate_every_containment_feature() + { + this.Load("rich.ecore", RichModel); + var resource = this.resourceSet.Resources.Single(); + + EObject? Resolve(string fragment) => resource.GetEObject($"rich.ecore#{fragment}"); + + Assert.Multiple(() => + { + Assert.That(((EPackage)Resolve("//@eSubpackages.0")!).Name, Is.EqualTo("sub")); + Assert.That(((EClass)Resolve("//@eSubpackages.0/@eClassifiers.0")!).Name, Is.EqualTo("InSub")); + Assert.That(Resolve("//@eClassifiers.0/@eAnnotations.0"), Is.InstanceOf()); + Assert.That(((ETypeParameter)Resolve("//@eClassifiers.0/@eTypeParameters.0")!).Name, Is.EqualTo("T")); + Assert.That(Resolve("//@eClassifiers.0/@eTypeParameters.0/@eBounds.0"), Is.InstanceOf()); + Assert.That(((EOperation)Resolve("//@eClassifiers.0/@eOperations.0")!).Name, Is.EqualTo("doIt")); + Assert.That(((ETypeParameter)Resolve("//@eClassifiers.0/@eOperations.0/@eTypeParameters.0")!).Name, Is.EqualTo("U")); + Assert.That(((EParameter)Resolve("//@eClassifiers.0/@eOperations.0/@eParameters.0")!).Name, Is.EqualTo("arg")); + Assert.That(Resolve("//@eClassifiers.0/@eOperations.0/@eGenericExceptions.0"), Is.InstanceOf()); + Assert.That(Resolve("//@eClassifiers.1/@eGenericSuperTypes.0"), Is.InstanceOf()); + Assert.That(((EEnumLiteral)Resolve("//@eClassifiers.2/@eLiterals.0")!).Name, Is.EqualTo("RED")); + }); + } + + [Test] + public void Verify_that_a_same_file_xmi_id_fragment_resolves() + { + var root = this.Load("ids.ecore", XmiIdModel); + + var node = Class(root, "Node"); + var parent = node.EStructuralFeatures.OfType().Single(r => r.Name == "parent"); + + // eType="#_node" is an intrinsic xmi:id reference; Node registers the id first, so a later + // duplicate xmi:id does not override it and the reference resolves back to Node + Assert.That(parent.EType, Is.SameAs(node)); + } + + [Test] + public void Verify_that_cross_file_positional_and_xmi_id_fragments_resolve() + { + // the target file must sit next to the source so the demand-load can find and load it; it is + // written to disk only (not pre-registered as a resource), mirroring real cross-file loading + WriteFile("target.ecore", TargetModel); + var root = this.Load("source.ecore", SourceModel); + + var source = Class(root, "SourceClass"); + var viaPosition = source.EStructuralFeatures.OfType().Single(r => r.Name == "viaPosition"); + var viaId = source.EStructuralFeatures.OfType().Single(r => r.Name == "viaId"); + + Assert.Multiple(() => + { + Assert.That(viaPosition.EType, Is.Not.Null); + Assert.That(viaPosition.EType!.Name, Is.EqualTo("TargetClass")); + + // both forms address the same object in the target resource + Assert.That(viaId.EType, Is.SameAs(viaPosition.EType)); + }); + } + + private static EClass Class(EPackage root, string name) + { + return root.EClassifiers.OfType().Single(c => c.Name == name); + } + + private static void WriteFile(string fileName, string content) + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, fileName); + File.WriteAllText(path, content); + } + + private Resource CreateResource(string fileName, string content) + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, fileName); + File.WriteAllText(path, content); + + return this.resourceSet.CreateResource(new Uri(path)); + } + + private EPackage Load(string fileName, string content) + { + var resource = this.CreateResource(fileName, content); + var root = resource.Load(null); + + Assert.That(resource.Errors, Is.Empty, string.Join("; ", resource.Errors.Select(e => e.Message))); + + return root; + } + + private const string Header = + "\r\n" + + "\r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + ""; + + private const string RichModel = + Header + + "name=\"rich\" nsURI=\"rich\" nsPrefix=\"rich\">\r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + ""; + + private const string XmiIdModel = + Header + + "name=\"ids\" nsURI=\"ids\" nsPrefix=\"ids\">\r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + ""; + + private const string TargetModel = + Header + + "name=\"target\" nsURI=\"target\" nsPrefix=\"target\">\r\n" + + " \r\n" + + ""; + + private const string SourceModel = + Header + + "name=\"source\" nsURI=\"source\" nsPrefix=\"source\">\r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + " \r\n" + + ""; + } +} diff --git a/ECoreNetto/ModelElement/EObject.cs b/ECoreNetto/ModelElement/EObject.cs index fe83015..612b116 100644 --- a/ECoreNetto/ModelElement/EObject.cs +++ b/ECoreNetto/ModelElement/EObject.cs @@ -347,9 +347,23 @@ public object EInvoke(EOperation operation, IEnumerable arguments) public virtual void ReadXml(XmlNode element) { this.SaveAttributes(element); + this.RegisterXmiId(); this.ReadChildNodes(element); } + /// + /// Registers this object in its under its intrinsic xmi:id, when it + /// carries one, so that a reference of the bare xmi:id form (a URI fragment without a leading + /// slash, resolved by EMF through getEObjectByID) can be resolved to this object. + /// + private void RegisterXmiId() + { + if (this.Attributes.TryGetValue("xmi:id", out var id) && !string.IsNullOrEmpty(id)) + { + this.EResource.RegisterEObjectId(id, this); + } + } + /// /// Set the properties of this /// diff --git a/ECoreNetto/Resource/Resource.cs b/ECoreNetto/Resource/Resource.cs index 43cdd22..1c08b68 100644 --- a/ECoreNetto/Resource/Resource.cs +++ b/ECoreNetto/Resource/Resource.cs @@ -65,6 +65,13 @@ public class Resource : Notifier /// private readonly Dictionary eCoreTypes; + /// + /// Maps the intrinsic xmi:id of an to that object, populated while the + /// resource is read. It backs resolution of the bare xmi:id URI-fragment form (a fragment + /// without a leading slash), which EMF resolves through getEObjectByID. + /// + private readonly Dictionary idToEObject; + /// /// The namespace URI prefix under which the built-in Ecore types are referenced, i.e. the part that /// precedes the //EName fragment in a fully-qualified reference such as @@ -153,6 +160,7 @@ public Resource(ILoggerFactory? loggerFactory = null) } this.Cache = new Dictionary(); + this.idToEObject = new Dictionary(); this.isLoaded = false; @@ -342,13 +350,22 @@ public string GetURIFragment(EObject eObject) filePart = filePart.Substring(typePrefixIndex + 2); } + // the part after '#' is the in-document fragment: a name-based path ('//A/b'), a positional path + // ('//@eClassifiers.3'), or a bare xmi:id. When there is no '#', the whole value may itself be a + // bare xmi:id. + var inDocumentFragment = uriFragments.Length > 1 ? uriFragments[1] : filePart; + if (!Path.HasExtension(filePart)) { - // the fragment does not point at another resource file and was not found in this resource's - // cache or the known ECore types: it cannot be resolved. - this.logger.LogTrace("EObject using uri fragment '{0}' could not be resolved", uriFragment); + // the fragment does not point at another resource file: resolve a positional path or an + // xmi:id within this resource, otherwise it cannot be resolved. + var localResolved = this.ResolveInDocumentFragment(inDocumentFragment); + if (localResolved == null) + { + this.logger.LogTrace("EObject using uri fragment '{0}' could not be resolved", uriFragment); + } - return null; + return localResolved; } // resolve the file part against the current resource URI (proper URI resolution handles escaped @@ -378,6 +395,15 @@ public string GetURIFragment(EObject eObject) resource.Load(null); } + // a positional path or an xmi:id is resolved structurally within the owning resource. That owning + // resource is this resource itself for a same-file positional reference such as + // 'file.ecore#//@eClassifiers.3', which carries the file name after reference rewriting. + var withinResource = resource.ResolveInDocumentFragment(inDocumentFragment); + if (withinResource != null) + { + return withinResource; + } + if (visitedResources.Contains(resource)) { // delegating to a resource already on the resolution path would repeat with the same @@ -423,6 +449,183 @@ public T GetEObject(string uriFragment) where T : EObject return typedEObject; } + /// + /// Resolves an in-document URI fragment against this resource: a positional path of the + /// //@feature.index form, or a bare xmi:id. + /// + /// + /// The in-document fragment (the part after '#', or the whole value when there is no '#'). + /// + /// + /// The resolved , or null when the fragment is not a positional path or a + /// known xmi:id. Pure name-based paths return null here because they are already resolved + /// through the identifier cache. + /// + private EObject? ResolveInDocumentFragment(string fragment) + { + if (string.IsNullOrEmpty(fragment)) + { + return null; + } + + if (fragment[0] == '/') + { + // a '/'-rooted path. Pure name paths are already covered by the identifier cache; only the + // positional '@feature.index' form needs structural navigation here. + return fragment.IndexOf('@') >= 0 ? this.ResolveStructuralFragment(fragment) : null; + } + + // a fragment without a leading slash is an intrinsic xmi:id + return this.idToEObject.TryGetValue(fragment, out var byId) ? byId : null; + } + + /// + /// Navigates a positional (structural) URI fragment such as + /// //@eClassifiers.3/@eStructuralFeatures.1 from the root of this resource, indexing into the + /// containment feature named by each @feature.index segment. This mirrors EMF's + /// eObjectForURIFragmentSegment. + /// + /// + /// The positional path, starting with a leading slash. + /// + /// + /// The resolved , or null when the path does not address an existing object. + /// + private EObject? ResolveStructuralFragment(string fragment) + { + // 'fragment' has a leading slash: split yields an empty first element (before that slash), then the + // resource root-position segment, then the '@feature.index' navigation segments. + var parts = fragment.Split('/'); + if (parts.Length < 2) + { + return null; + } + + var rootSegment = parts[1]; + if (rootSegment.Length != 0 && rootSegment != "0") + { + // a non-zero resource root position addresses a second root object; ECoreNetto resources are + // single-root, so this cannot be resolved. + return null; + } + + var current = this.QueryRootObject(); + + for (var i = 2; i < parts.Length && current != null; i++) + { + current = NavigateContainmentSegment(current, parts[i]); + } + + return current; + } + + /// + /// Returns the root object of this resource, i.e. the object at position 0 of its contents. + /// + /// + /// The root , or null when the resource has no contents. + /// + /// + /// While the resource is still being loaded its are not yet populated, so the + /// root is taken as the single cached object that has no container. + /// + private EObject? QueryRootObject() + { + if (this.Contents.Count > 0) + { + return this.Contents[0]; + } + + return this.Cache.Values.FirstOrDefault(o => o.EContainer == null); + } + + /// + /// Resolves a single positional navigation segment of the @feature.index form against the + /// given owner, returning the child at that index of the named containment feature. + /// + /// + /// The object the segment is navigated from. + /// + /// + /// The @feature.index segment. + /// + /// + /// The addressed child, or null when the segment is malformed, names an unknown containment feature, + /// or the index is out of range. + /// + private static EObject? NavigateContainmentSegment(EObject owner, string segment) + { + if (segment.Length == 0 || segment[0] != '@') + { + return null; + } + + var body = segment.Substring(1); + var separator = body.LastIndexOf('.'); + if (separator < 0) + { + return null; + } + + var featureName = body.Substring(0, separator); + if (!int.TryParse(body.Substring(separator + 1), out var index) || index < 0) + { + return null; + } + + var containment = QueryContainmentList(owner, featureName); + if (containment == null || index >= containment.Count) + { + return null; + } + + return containment[index]; + } + + /// + /// Returns the ordered containment list of for the containment feature named + /// , or null when the owner has no such containment feature. + /// + /// + /// The object whose containment feature is requested. + /// + /// + /// The Ecore name of the containment feature (e.g. eClassifiers, eStructuralFeatures). + /// + /// + /// The containment list, or null. + /// + private static IReadOnlyList? QueryContainmentList(EObject owner, string featureName) + { + switch (featureName) + { + case "eAnnotations": + return (owner as EModelElement)?.EAnnotations; + case "eSubpackages": + return (owner as EPackage)?.ESubPackages; + case "eClassifiers": + return (owner as EPackage)?.EClassifiers; + case "eStructuralFeatures": + return (owner as EClass)?.EStructuralFeatures; + case "eOperations": + return (owner as EClass)?.EOperations; + case "eGenericSuperTypes": + return (owner as EClass)?.EGenericSuperTypes; + case "eLiterals": + return (owner as EEnum)?.ELiterals; + case "eParameters": + return (owner as EOperation)?.EParameters; + case "eGenericExceptions": + return (owner as EOperation)?.EGenericExceptions; + case "eTypeParameters": + return (owner as EClassifier)?.ETypeParameters ?? (owner as EOperation)?.ETypeParameters; + case "eBounds": + return (owner as ETypeParameter)?.EBounds; + default: + return null; + } + } + /// /// Saves the resource using the specified options. /// @@ -537,6 +740,28 @@ internal void AddError(string message) this.errors.Add(new Diagnostic(0, 0, this.URI?.AbsoluteUri ?? string.Empty, message)); } + /// + /// Registers the given under its intrinsic xmi:id so that a bare + /// xmi:id URI fragment can be resolved to it. + /// + /// + /// The xmi:id value carried by . + /// + /// + /// The object to register. + /// + /// + /// The first registration for a given wins. A duplicate xmi:id is + /// invalid, but recording it must not abort the load, so later duplicates are ignored. + /// + internal void RegisterEObjectId(string id, EObject eObject) + { + if (!this.idToEObject.ContainsKey(id)) + { + this.idToEObject.Add(id, eObject); + } + } + /// /// Gets an of the errors in the resource; ///