Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Semantics.Strings/ISemanticString.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ public interface ISemanticString : IComparable, IComparable<ISemanticString>, IE
/// <remarks>
/// Use this property when you need to interoperate with APIs that expect regular strings.
/// The name "WeakString" emphasizes that this breaks the type safety guarantees.
/// The value is read-only through this interface: it is set when the semantic string is created,
/// which is where it is canonicalized and validated (#310).
/// </remarks>
public string WeakString { get; init; }
public string WeakString { get; }

// System.String interface

Expand Down
12 changes: 8 additions & 4 deletions Semantics.Strings/SemanticString.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public TDest As<TDest>()

// ISemanticString implementation
/// <summary>
/// Gets or initializes the underlying string value for interoperability with non-semantic string operations.
/// Gets the underlying string value for interoperability with non-semantic string operations.
/// </summary>
/// <value>
/// The raw string value contained within this semantic string. Defaults to <see cref="string.Empty"/>.
Expand All @@ -80,9 +80,12 @@ public TDest As<TDest>()
/// Use this property when you need to interoperate with APIs that expect regular strings.
/// The name "WeakString" emphasizes that accessing this property breaks the type safety guarantees
/// that semantic strings provide. Consider using implicit conversion to string instead when possible.
/// This property is init-only to support record initialization while maintaining immutability.
/// The init accessor is protected so that a value can only arrive through <c>Create</c>, which
/// canonicalizes and validates it. A public one let <c>uuid with { WeakString = "not a uuid" }</c>
/// build an instance that never passed either (#310). A derived type may still set it from its own
/// constructor.
/// </remarks>
public string WeakString { get; init; } = string.Empty;
public string WeakString { get; protected init; } = string.Empty;

/// <inheritdoc/>
public int Length => WeakString.Length;
Expand Down Expand Up @@ -635,14 +638,15 @@ private static TDest FromStringInternal<TDest>(string? value)

/// <summary>
/// The <see cref="WeakString"/> init accessor as a delegate, looked up once per type rather than per value (#444).
/// The accessor is protected (#310), so the lookup asks for non-public accessors too.
/// </summary>
/// <typeparam name="TDest">The semantic string type to set the value of.</typeparam>
private static class WeakStringSetter<TDest>
where TDest : SemanticString<TDest>
{
internal static readonly Action<TDest, string> Set = (Action<TDest, string>)Delegate.CreateDelegate(
typeof(Action<TDest, string>),
Ensure.NotNull(typeof(TDest).GetProperty(name: nameof(WeakString))?.GetSetMethod()));
Ensure.NotNull(typeof(TDest).GetProperty(name: nameof(WeakString))?.GetSetMethod(nonPublic: true)));
}

/// <summary>
Expand Down
66 changes: 66 additions & 0 deletions Semantics.Test/WeakStringAccessTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Semantics.Test;

using System;
using System.IO;
using System.Reflection;
using ktsu.Semantics.Paths;
using ktsu.Semantics.Strings;
using ktsu.Semantics.Strings.Identifiers;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// A semantic string's value only arrives through <c>Create</c>, which canonicalizes and validates it.
/// A public init accessor let <c>uuid with { WeakString = "not a uuid" }</c> skip both (#310).
/// </summary>
[TestClass]
public class WeakStringAccessTests
{
[TestMethod]
public void WeakString_HasNoPublicSetter()
{
Type[] types =
[
typeof(MySemanticString),
typeof(Uuid),
typeof(AbsoluteDirectoryPath),
typeof(AbsoluteFilePath),
typeof(RelativeDirectoryPath),
];

foreach (Type type in types)
{
PropertyInfo property = type.GetProperty(nameof(ISemanticString.WeakString))!;

Assert.IsNotNull(property.GetGetMethod(), $"{type.Name}.WeakString should stay publicly readable.");
Assert.IsNull(property.GetSetMethod(), $"{type.Name}.WeakString should not be publicly settable.");
}
}

[TestMethod]
public void ISemanticString_WeakString_IsReadOnly()
{
PropertyInfo property = typeof(ISemanticString).GetProperty(nameof(ISemanticString.WeakString))!;

Assert.IsFalse(property.CanWrite);
}

[TestMethod]
public void Create_StillSetsTheValue()
{
const string value = "123e4567-e89b-12d3-a456-426614174000";

Assert.AreEqual(value, Uuid.Create(value).WeakString);
Assert.AreEqual("test", MySemanticString.Create("test").WeakString);
}

[TestMethod]
public void Create_StillSetsThePathValue()
{
AbsoluteDirectoryPath parent = AbsoluteDirectoryPath.Create(Path.GetTempPath());
AbsoluteDirectoryPath child = AbsoluteDirectoryPath.Create(Path.Join(parent.WeakString, "weakstring"));

Assert.AreEqual(parent, child.Parent);
}
}
Loading