diff --git a/uml4net.CodeGenerator.Tests/CSharpKeywordsTestFixture.cs b/uml4net.CodeGenerator.Tests/CSharpKeywordsTestFixture.cs
new file mode 100644
index 00000000..798e07f0
--- /dev/null
+++ b/uml4net.CodeGenerator.Tests/CSharpKeywordsTestFixture.cs
@@ -0,0 +1,65 @@
+// -------------------------------------------------------------------------------------------------
+//
+//
+// Copyright (C) 2019-2026 Starion Group S.A.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+//
+// ------------------------------------------------------------------------------------------------
+
+namespace uml4net.CodeGenerator.Tests
+{
+ using System;
+ using System.Linq;
+
+ using Microsoft.CodeAnalysis.CSharp;
+
+ using NUnit.Framework;
+
+ using uml4net.Extensions;
+
+ ///
+ /// Verifies the list of reserved C# keywords of against the C# compiler. The
+ /// uml4net.Extensions library does not reference the compiler, this test project does.
+ ///
+ [TestFixture]
+ public class CSharpKeywordsTestFixture
+ {
+ [Test]
+ public void Verify_that_the_reserved_keywords_are_those_of_the_CSharp_compiler()
+ {
+ // the compiler also reserves four undocumented keywords that start with two underscores (__arglist,
+ // __makeref, __reftype and __refvalue); a model element is not expected to have such a name
+ var compilerKeywords = SyntaxFacts.GetReservedKeywordKinds()
+ .Select(SyntaxFacts.GetText)
+ .Where(x => !x.StartsWith("__", StringComparison.Ordinal))
+ .OrderBy(x => x, StringComparer.Ordinal)
+ .ToList();
+
+ Assert.That(StringExtensions.QueryCSharpKeywords(), Is.EqualTo(compilerKeywords));
+ }
+
+ [Test]
+ public void Verify_that_a_contextual_keyword_is_not_a_reserved_keyword()
+ {
+ var contextualKeywords = SyntaxFacts.GetContextualKeywordKinds().Select(SyntaxFacts.GetText).ToList();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(contextualKeywords, Does.Contain("partial"));
+ Assert.That(contextualKeywords.Where(x => x.QueryIsCSharpKeyword()), Is.Empty);
+ }
+ }
+ }
+}
diff --git a/uml4net.Extensions.Tests/NamedElementExtensionsTestFixture.cs b/uml4net.Extensions.Tests/NamedElementExtensionsTestFixture.cs
index 48c3d270..3e3c28ac 100644
--- a/uml4net.Extensions.Tests/NamedElementExtensionsTestFixture.cs
+++ b/uml4net.Extensions.Tests/NamedElementExtensionsTestFixture.cs
@@ -20,6 +20,7 @@
namespace uml4net.Extensions.Tests
{
+ using System;
using System.IO;
using System.Linq;
using Classification;
@@ -85,5 +86,66 @@ public void Verify_that_QueryNamespace_returns_expected_resul()
Assert.That(connector.QueryNamespace(), Is.EqualTo("UML.StructuredClassifiers.Connector"));
}
}
+
+ [Test]
+ public void Verify_that_QueryCSharpIdentifier_applies_the_casing_and_escapes_a_keyword()
+ {
+ var @class = new Property { Name = "class" };
+ var ownedComment = new Property { Name = "ownedComment" };
+ var upperCase = new Class { Name = "Event" };
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(@class.QueryCSharpIdentifier(), Is.EqualTo("@class"), "as modelled, a keyword");
+ Assert.That(@class.QueryCSharpIdentifier(CSharpIdentifierCasing.LowerCaseFirstLetter), Is.EqualTo("@class"));
+ Assert.That(@class.QueryCSharpIdentifier(CSharpIdentifierCasing.UpperCaseFirstLetter), Is.EqualTo("Class"), "not a keyword after the casing is applied");
+
+ Assert.That(ownedComment.QueryCSharpIdentifier(), Is.EqualTo("ownedComment"));
+ Assert.That(ownedComment.QueryCSharpIdentifier(CSharpIdentifierCasing.UpperCaseFirstLetter), Is.EqualTo("OwnedComment"));
+
+ Assert.That(upperCase.QueryCSharpIdentifier(), Is.EqualTo("Event"));
+ Assert.That(upperCase.QueryCSharpIdentifier(CSharpIdentifierCasing.LowerCaseFirstLetter), Is.EqualTo("@event"), "a keyword after the casing is applied");
+ }
+ }
+
+ [Test]
+ public void Verify_that_QueryCSharpIdentifier_throws_for_an_element_without_a_name()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(() => new Property { XmiId = "id-1" }.QueryCSharpIdentifier(),
+ Throws.InvalidOperationException.With.Message.Contains("id-1").And.Message.Contains("Property"));
+ Assert.That(() => new Property { Name = " " }.QueryCSharpIdentifier(), Throws.InvalidOperationException);
+ Assert.That(() => NamedElementExtensions.QueryCSharpIdentifier(null), Throws.ArgumentNullException);
+ Assert.That(() => new Property().QueryCSharpIdentifier(), Throws.InvalidOperationException.With.Message.Contains("xmi:id ''"), "an element without a name and without an xmi:id");
+ Assert.That(() => new Property { Name = "class" }.QueryCSharpIdentifier((CSharpIdentifierCasing)99), Throws.TypeOf());
+ }
+ }
+
+ [Test]
+ [NonParallelizable]
+ public void Verify_that_QueryCSharpIdentifier_escapes_a_registered_keyword()
+ {
+ var value = new Property { Name = "value" };
+ var upperCase = new Property { Name = "Var" };
+
+ try
+ {
+ StringExtensions.RegisterAdditionalCSharpKeywords("var", "value");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(value.QueryCSharpIdentifier(), Is.EqualTo("@value"));
+ Assert.That(value.QueryCSharpIdentifier(CSharpIdentifierCasing.UpperCaseFirstLetter), Is.EqualTo("Value"));
+ Assert.That(upperCase.QueryCSharpIdentifier(CSharpIdentifierCasing.LowerCaseFirstLetter), Is.EqualTo("@var"));
+ }
+ }
+ finally
+ {
+ StringExtensions.ClearAdditionalCSharpKeywords();
+ }
+
+ Assert.That(value.QueryCSharpIdentifier(), Is.EqualTo("value"));
+ }
}
}
diff --git a/uml4net.Extensions.Tests/StringExtensionsTestFixture.cs b/uml4net.Extensions.Tests/StringExtensionsTestFixture.cs
index d2b6422a..08eb7d61 100644
--- a/uml4net.Extensions.Tests/StringExtensionsTestFixture.cs
+++ b/uml4net.Extensions.Tests/StringExtensionsTestFixture.cs
@@ -21,7 +21,9 @@
namespace uml4net.Extensions.Tests
{
using System;
+ using System.Collections.Generic;
using System.Linq;
+ using System.Threading.Tasks;
using NUnit.Framework;
@@ -87,5 +89,166 @@ public void Verify_that_LowerCaseFirstLetter_throws_when_input_is_null_or_empty(
Assert.That(() => string.Empty.LowerCaseFirstLetter(), Throws.TypeOf());
}
}
+
+ [Test]
+ public void Verify_that_QueryIsCSharpKeyword_recognizes_the_reserved_keywords()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That("class".QueryIsCSharpKeyword(), Is.True);
+ Assert.That("in".QueryIsCSharpKeyword(), Is.True);
+ Assert.That("true".QueryIsCSharpKeyword(), Is.True);
+ Assert.That("Class".QueryIsCSharpKeyword(), Is.False, "the comparison is case-sensitive");
+ Assert.That("var".QueryIsCSharpKeyword(), Is.False, "a contextual keyword is a legal identifier");
+ Assert.That("type".QueryIsCSharpKeyword(), Is.False);
+ Assert.That(string.Empty.QueryIsCSharpKeyword(), Is.False);
+ Assert.That(() => StringExtensions.QueryIsCSharpKeyword(null), Throws.ArgumentNullException);
+
+ Assert.That(StringExtensions.QueryCSharpKeywords(), Has.Count.EqualTo(77));
+ Assert.That(StringExtensions.QueryCSharpKeywords(), Is.Ordered.Using((IComparer)StringComparer.Ordinal));
+ Assert.That(StringExtensions.QueryCSharpKeywords().All(x => x.QueryIsCSharpKeyword()), Is.True);
+ }
+ }
+
+ [Test]
+ public void Verify_that_EscapeCSharpKeyword_prefixes_a_reserved_keyword()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That("class".EscapeCSharpKeyword(), Is.EqualTo("@class"));
+ Assert.That("event".EscapeCSharpKeyword(), Is.EqualTo("@event"));
+ Assert.That("Class".EscapeCSharpKeyword(), Is.EqualTo("Class"));
+ Assert.That("ownedComment".EscapeCSharpKeyword(), Is.EqualTo("ownedComment"));
+ Assert.That("var".EscapeCSharpKeyword(), Is.EqualTo("var"));
+ Assert.That("@class".EscapeCSharpKeyword(), Is.EqualTo("@class"), "a string that is already escaped is not escaped again");
+ Assert.That("class ".EscapeCSharpKeyword(), Is.EqualTo("class "), "the string is not trimmed");
+ Assert.That(string.Empty.EscapeCSharpKeyword(), Is.Empty);
+ Assert.That(() => StringExtensions.EscapeCSharpKeyword(null), Throws.ArgumentNullException);
+ }
+ }
+
+ [Test]
+ [NonParallelizable]
+ public void Verify_that_RegisterAdditionalCSharpKeywords_extends_the_reserved_keywords()
+ {
+ try
+ {
+ StringExtensions.RegisterAdditionalCSharpKeywords("var", "value");
+ StringExtensions.RegisterAdditionalCSharpKeywords("value", "class", "record");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That("var".QueryIsCSharpKeyword(), Is.True);
+ Assert.That("value".QueryIsCSharpKeyword(), Is.True);
+ Assert.That("Var".QueryIsCSharpKeyword(), Is.False, "the comparison is case-sensitive");
+ Assert.That("class".QueryIsCSharpKeyword(), Is.True);
+
+ Assert.That("var".EscapeCSharpKeyword(), Is.EqualTo("@var"));
+ Assert.That("value".EscapeCSharpKeyword(), Is.EqualTo("@value"));
+ Assert.That("Value".EscapeCSharpKeyword(), Is.EqualTo("Value"));
+
+ Assert.That(StringExtensions.QueryAdditionalCSharpKeywords(), Is.EqualTo(new[] { "record", "value", "var" }),
+ "a reserved keyword of C# and a word that is registered twice are ignored");
+ Assert.That(StringExtensions.QueryCSharpKeywords(), Has.Count.EqualTo(77));
+ }
+ }
+ finally
+ {
+ StringExtensions.ClearAdditionalCSharpKeywords();
+ }
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(StringExtensions.QueryAdditionalCSharpKeywords(), Is.Empty);
+ Assert.That("var".QueryIsCSharpKeyword(), Is.False);
+ Assert.That("value".EscapeCSharpKeyword(), Is.EqualTo("value"));
+ }
+ }
+
+ [Test]
+ [NonParallelizable]
+ public void Verify_that_RegisterAdditionalCSharpKeywords_rejects_invalid_keywords()
+ {
+ try
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(() => StringExtensions.RegisterAdditionalCSharpKeywords(null), Throws.ArgumentNullException);
+ Assert.That(() => StringExtensions.RegisterAdditionalCSharpKeywords("var", null), Throws.ArgumentException);
+ Assert.That(() => StringExtensions.RegisterAdditionalCSharpKeywords("var", string.Empty), Throws.ArgumentException);
+ Assert.That(() => StringExtensions.RegisterAdditionalCSharpKeywords("var", " "), Throws.ArgumentException);
+ Assert.That(() => StringExtensions.RegisterAdditionalCSharpKeywords("var", " value"), Throws.ArgumentException, "a word that starts with white space never equals a name");
+ Assert.That(() => StringExtensions.RegisterAdditionalCSharpKeywords("var", "value "), Throws.ArgumentException);
+ Assert.That(() => StringExtensions.RegisterAdditionalCSharpKeywords("var", "@value"), Throws.ArgumentException);
+
+ Assert.That(StringExtensions.QueryAdditionalCSharpKeywords(), Is.Empty, "nothing is registered when one of the keywords is invalid");
+ Assert.That(() => StringExtensions.RegisterAdditionalCSharpKeywords(), Throws.Nothing);
+ }
+ }
+ finally
+ {
+ StringExtensions.ClearAdditionalCSharpKeywords();
+ }
+ }
+
+ [Test]
+ [NonParallelizable]
+ public void Verify_that_the_keyword_queries_return_a_read_only_snapshot()
+ {
+ try
+ {
+ StringExtensions.RegisterAdditionalCSharpKeywords("var");
+
+ var snapshot = StringExtensions.QueryAdditionalCSharpKeywords();
+
+ StringExtensions.RegisterAdditionalCSharpKeywords("value");
+ StringExtensions.ClearAdditionalCSharpKeywords();
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(snapshot, Is.EqualTo(new[] { "var" }), "a later registration or clear does not change a list that was returned before");
+ Assert.That(() => ((IList)snapshot).Add("x"), Throws.TypeOf());
+ Assert.That(() => ((IList)StringExtensions.QueryCSharpKeywords()).Add("x"), Throws.TypeOf());
+ Assert.That(StringExtensions.QueryCSharpKeywords(), Has.Count.EqualTo(77));
+ }
+ }
+ finally
+ {
+ StringExtensions.ClearAdditionalCSharpKeywords();
+ }
+ }
+
+ [Test]
+ [NonParallelizable]
+ public void Verify_that_the_additional_keywords_can_be_registered_and_queried_from_several_threads()
+ {
+ const int threads = 8;
+ const int wordsPerThread = 200;
+
+ try
+ {
+ var tasks = Enumerable.Range(0, threads).Select(thread => Task.Run(() =>
+ {
+ for (var i = 0; i < wordsPerThread; i++)
+ {
+ var word = $"word_{thread}_{i}";
+
+ StringExtensions.RegisterAdditionalCSharpKeywords(word);
+
+ if (!word.QueryIsCSharpKeyword() || !"class".QueryIsCSharpKeyword() || "Class".QueryIsCSharpKeyword())
+ {
+ throw new InvalidOperationException($"{word} is not found after it has been registered");
+ }
+ }
+ })).ToArray();
+
+ Assert.That(() => Task.WaitAll(tasks), Throws.Nothing);
+ Assert.That(StringExtensions.QueryAdditionalCSharpKeywords(), Has.Count.EqualTo(threads * wordsPerThread), "no registration is lost");
+ }
+ finally
+ {
+ StringExtensions.ClearAdditionalCSharpKeywords();
+ }
+ }
}
}
diff --git a/uml4net.Extensions/CSharpIdentifierCasing.cs b/uml4net.Extensions/CSharpIdentifierCasing.cs
new file mode 100644
index 00000000..92b9027e
--- /dev/null
+++ b/uml4net.Extensions/CSharpIdentifierCasing.cs
@@ -0,0 +1,44 @@
+// -------------------------------------------------------------------------------------------------
+//
+//
+// Copyright (C) 2019-2026 Starion Group S.A.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+//
+// ------------------------------------------------------------------------------------------------
+
+namespace uml4net.Extensions
+{
+ ///
+ /// The casing of the first letter of a C# identifier that is derived from the name of a model element, see
+ ///
+ ///
+ public enum CSharpIdentifierCasing
+ {
+ ///
+ /// The name is used as it is in the model
+ ///
+ AsModelled = 0,
+
+ ///
+ /// The first letter of the name is converted to upper case
+ ///
+ UpperCaseFirstLetter = 1,
+
+ ///
+ /// The first letter of the name is converted to lower case
+ ///
+ LowerCaseFirstLetter = 2
+ }
+}
diff --git a/uml4net.Extensions/NamedElementExtensions.cs b/uml4net.Extensions/NamedElementExtensions.cs
index ec70fb32..8b8a36c4 100644
--- a/uml4net.Extensions/NamedElementExtensions.cs
+++ b/uml4net.Extensions/NamedElementExtensions.cs
@@ -62,5 +62,64 @@ public static string QueryNamespace(this INamedElement namedElement)
return sb.ToString();
}
+
+ ///
+ /// Queries the C# identifier for the : its name, with the requested casing of the
+ /// first letter, escaped when the result is a reserved keyword of C# or a word that has been registered with
+ ///
+ ///
+ ///
+ /// The subject
+ ///
+ ///
+ /// The of the first letter of the identifier
+ ///
+ ///
+ /// The name of the with the requested , prefixed
+ /// with @ when that is a reserved keyword of C# (see )
+ ///
+ ///
+ /// Thrown when the has no name. NamedElement::name is optional in UML,
+ /// but an identifier cannot be derived from an element without a name.
+ ///
+ ///
+ /// Thrown when the is not a value of the
+ /// enumeration
+ ///
+ ///
+ /// Only the first letter is changed and only keywords are escaped. A name that holds characters that are not
+ /// allowed in a C# identifier, such as a space or an operator symbol, is returned with those characters.
+ ///
+ public static string QueryCSharpIdentifier(this INamedElement namedElement, CSharpIdentifierCasing casing = CSharpIdentifierCasing.AsModelled)
+ {
+ if (namedElement == null)
+ {
+ throw new ArgumentNullException(nameof(namedElement));
+ }
+
+ if (string.IsNullOrWhiteSpace(namedElement.Name))
+ {
+ throw new InvalidOperationException($"The {namedElement.GetType().Name} with xmi:id '{namedElement.XmiId}' has no name, a C# identifier cannot be derived from it");
+ }
+
+ string identifier;
+
+ switch (casing)
+ {
+ case CSharpIdentifierCasing.UpperCaseFirstLetter:
+ identifier = namedElement.Name.CapitalizeFirstLetter();
+ break;
+ case CSharpIdentifierCasing.LowerCaseFirstLetter:
+ identifier = namedElement.Name.LowerCaseFirstLetter();
+ break;
+ case CSharpIdentifierCasing.AsModelled:
+ identifier = namedElement.Name;
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(casing), casing, "The casing is not a value of the CSharpIdentifierCasing enumeration");
+ }
+
+ return identifier.EscapeCSharpKeyword();
+ }
}
}
diff --git a/uml4net.Extensions/StringExtensions.cs b/uml4net.Extensions/StringExtensions.cs
index b6775968..a46ff72d 100644
--- a/uml4net.Extensions/StringExtensions.cs
+++ b/uml4net.Extensions/StringExtensions.cs
@@ -24,6 +24,7 @@ namespace uml4net.Extensions
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
+ using System.Threading;
///
/// Extension methods for
@@ -125,5 +126,160 @@ public static string LowerCaseFirstLetter(this string input)
return string.Concat(input[0].ToString(CultureInfo.InvariantCulture).ToLower(CultureInfo.InvariantCulture), input.Substring(1));
}
+
+ ///
+ /// The reserved keywords of C#. A reserved keyword cannot be used as an identifier unless it is prefixed
+ /// with @.
+ ///
+ ///
+ /// The contextual keywords of C#, such as var, value and record, are not part of this
+ /// set: they only have a meaning in a specific context and are legal identifiers. A project that wants to
+ /// escape them as well registers them with .
+ ///
+ private static readonly HashSet ReservedCSharpKeywords = new HashSet(StringComparer.Ordinal)
+ {
+ "abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked", "class", "const",
+ "continue", "decimal", "default", "delegate", "do", "double", "else", "enum", "event", "explicit", "extern",
+ "false", "finally", "fixed", "float", "for", "foreach", "goto", "if", "implicit", "in", "int", "interface",
+ "internal", "is", "lock", "long", "namespace", "new", "null", "object", "operator", "out", "override",
+ "params", "private", "protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short",
+ "sizeof", "stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", "try", "typeof",
+ "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", "void", "volatile", "while"
+ };
+
+ ///
+ /// The words that a project has registered in addition to the reserved keywords of C#
+ /// (see ). The set is replaced, never changed, so that it can
+ /// be read without a lock.
+ ///
+ private static HashSet additionalCSharpKeywords = new HashSet(StringComparer.Ordinal);
+
+ ///
+ /// The lock that serializes the replacement of the
+ ///
+ private static readonly object AdditionalCSharpKeywordsLock = new object();
+
+ ///
+ /// Queries the reserved keywords of C#
+ ///
+ ///
+ /// The reserved keywords of C#, in alphabetical order. The contextual keywords are not included, and neither
+ /// are the words that have been registered with .
+ ///
+ public static IReadOnlyList QueryCSharpKeywords()
+ {
+ return ReservedCSharpKeywords.OrderBy(x => x, StringComparer.Ordinal).ToList().AsReadOnly();
+ }
+
+ ///
+ /// Registers words that are to be treated as a reserved keyword of C# in addition to the reserved keywords
+ /// of the language, for example the contextual keywords var and value
+ ///
+ ///
+ /// The words to register. The comparison is case-sensitive. A word that is a reserved keyword of C# or that
+ /// has already been registered is ignored.
+ ///
+ ///
+ /// The registration applies to the whole application domain: ,
+ /// and every extension method and Handlebars helper that is based on them
+ /// take the registered words into account. A code generator registers its words once, before it generates.
+ ///
+ ///
+ /// Thrown when is null
+ ///
+ ///
+ /// Thrown when one of the is null, empty or white space, starts or ends with
+ /// white space, or starts with @. Nothing is registered in that case.
+ ///
+ public static void RegisterAdditionalCSharpKeywords(params string[] keywords)
+ {
+ if (keywords == null)
+ {
+ throw new ArgumentNullException(nameof(keywords));
+ }
+
+ if (keywords.Any(string.IsNullOrWhiteSpace))
+ {
+ throw new ArgumentException("a keyword can't be null, empty or white space", nameof(keywords));
+ }
+
+ if (keywords.Any(x => char.IsWhiteSpace(x[0]) || char.IsWhiteSpace(x[x.Length - 1]) || x[0] == '@'))
+ {
+ throw new ArgumentException("a keyword can't start or end with white space and can't start with @, such a word never equals a name", nameof(keywords));
+ }
+
+ lock (AdditionalCSharpKeywordsLock)
+ {
+ var updated = new HashSet(additionalCSharpKeywords, StringComparer.Ordinal);
+
+ foreach (var keyword in keywords.Where(x => !ReservedCSharpKeywords.Contains(x)))
+ {
+ updated.Add(keyword);
+ }
+
+ Volatile.Write(ref additionalCSharpKeywords, updated);
+ }
+ }
+
+ ///
+ /// Queries the words that have been registered with
+ ///
+ ///
+ /// The registered words, in alphabetical order. The reserved keywords of C# are not included.
+ ///
+ public static IReadOnlyList QueryAdditionalCSharpKeywords()
+ {
+ return Volatile.Read(ref additionalCSharpKeywords).OrderBy(x => x, StringComparer.Ordinal).ToList().AsReadOnly();
+ }
+
+ ///
+ /// Removes the words that have been registered with , so that
+ /// only the reserved keywords of C# remain
+ ///
+ public static void ClearAdditionalCSharpKeywords()
+ {
+ lock (AdditionalCSharpKeywordsLock)
+ {
+ Volatile.Write(ref additionalCSharpKeywords, new HashSet(StringComparer.Ordinal));
+ }
+ }
+
+ ///
+ /// Queries whether the string is a reserved keyword of C#, or a word that has been registered with
+ ///
+ ///
+ ///
+ /// The subject input string
+ ///
+ ///
+ /// true when the is a reserved keyword of C# or a registered word, false otherwise.
+ /// The comparison is case-sensitive: class is a keyword, Class is not. A contextual keyword
+ /// such as var is not a reserved keyword, unless it has been registered.
+ ///
+ public static bool QueryIsCSharpKeyword(this string input)
+ {
+ if (input == null)
+ {
+ throw new ArgumentNullException(nameof(input));
+ }
+
+ return ReservedCSharpKeywords.Contains(input) || Volatile.Read(ref additionalCSharpKeywords).Contains(input);
+ }
+
+ ///
+ /// Escapes the string when it is a reserved keyword of C# or a word that has been registered with
+ /// , so that it can be used as an identifier
+ ///
+ ///
+ /// The subject input string
+ ///
+ ///
+ /// The prefixed with @ when it is a reserved keyword of C# or a registered
+ /// word (see ), the unchanged otherwise
+ ///
+ public static string EscapeCSharpKeyword(this string input)
+ {
+ return input.QueryIsCSharpKeyword() ? $"@{input}" : input;
+ }
}
}
diff --git a/uml4net.HandleBars.Tests/NamedElementHelperTestFixture.cs b/uml4net.HandleBars.Tests/NamedElementHelperTestFixture.cs
index ae329a84..27d9dde1 100644
--- a/uml4net.HandleBars.Tests/NamedElementHelperTestFixture.cs
+++ b/uml4net.HandleBars.Tests/NamedElementHelperTestFixture.cs
@@ -27,6 +27,7 @@ namespace uml4net.HandleBars.Tests
using NUnit.Framework;
+ using uml4net.Extensions;
using uml4net.HandleBars;
using uml4net.Packages;
using uml4net.StructuredClassifiers;
@@ -71,5 +72,71 @@ public void Verify_that_WriteNameSpace_throws_when_context_is_invalid()
Assert.That(() => action(5), Throws.TypeOf());
}
+
+ [Test]
+ public void Verify_that_WriteCSharpIdentifier_writes_the_name_with_the_requested_casing()
+ {
+ var asModelled = this.handlebarsContext.Compile("{{ #NamedElement.WriteCSharpIdentifier this }}");
+ var upperCase = this.handlebarsContext.Compile("{{ #NamedElement.WriteCSharpIdentifier this \"UpperCaseFirstLetter\" }}");
+ var lowerCase = this.handlebarsContext.Compile("{{ #NamedElement.WriteCSharpIdentifier this \"lowercasefirstletter\" }}");
+
+ var @class = new Class { Name = "class" };
+ var @event = new Class { Name = "Event" };
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(asModelled(@class), Is.EqualTo("@class"));
+ Assert.That(upperCase(@class), Is.EqualTo("Class"));
+ Assert.That(asModelled(@event), Is.EqualTo("Event"));
+ Assert.That(lowerCase(@event), Is.EqualTo("@event"), "the casing argument is not case-sensitive");
+
+ Assert.That(() => asModelled("not an element"), Throws.TypeOf());
+ Assert.That(() => asModelled(new Class()), Throws.InvalidOperationException, "an element without a name");
+ Assert.That(() => this.handlebarsContext.Compile("{{ #NamedElement.WriteCSharpIdentifier this \"Shouting\" }}")(@class), Throws.TypeOf());
+ Assert.That(() => this.handlebarsContext.Compile("{{ #NamedElement.WriteCSharpIdentifier }}")(@class), Throws.TypeOf());
+ Assert.That(() => this.handlebarsContext.Compile("{{ #NamedElement.WriteCSharpIdentifier this \"AsModelled\" \"x\" }}")(@class), Throws.TypeOf());
+ Assert.That(this.handlebarsContext.Compile("{{ #NamedElement.WriteCSharpIdentifier this \"AsModelled\" }}")(@class), Is.EqualTo("@class"));
+ }
+ }
+
+ [Test]
+ public void Verify_that_WriteCSharpIdentifier_rejects_a_casing_that_is_not_one_of_the_three_names()
+ {
+ var @class = new Class { Name = "class" };
+
+ var withCasing = this.handlebarsContext.Compile("{{ #NamedElement.WriteCSharpIdentifier this.Element this.Casing }}");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(() => withCasing(new { Element = @class, Casing = "1" }), Throws.TypeOf(), "the number of a value");
+ Assert.That(() => withCasing(new { Element = @class, Casing = "7" }), Throws.TypeOf(), "a number that is not a value");
+ Assert.That(() => withCasing(new { Element = @class, Casing = "AsModelled,UpperCaseFirstLetter" }), Throws.TypeOf(), "a list of names");
+ Assert.That(() => withCasing(new { Element = @class, Casing = string.Empty }), Throws.TypeOf());
+ Assert.That(() => withCasing(new { Element = @class, Casing = 5 }), Throws.TypeOf(), "not a string");
+
+ Assert.That(withCasing(new { Element = @class, Casing = "UpperCaseFirstLetter" }), Is.EqualTo("Class"));
+ }
+ }
+
+ [Test]
+ [NonParallelizable]
+ public void Verify_that_WriteCSharpIdentifier_escapes_a_registered_keyword()
+ {
+ var action = this.handlebarsContext.Compile("{{ #NamedElement.WriteCSharpIdentifier this }}");
+ var value = new Class { Name = "value" };
+
+ try
+ {
+ StringExtensions.RegisterAdditionalCSharpKeywords("value");
+
+ Assert.That(action(value), Is.EqualTo("@value"));
+ }
+ finally
+ {
+ StringExtensions.ClearAdditionalCSharpKeywords();
+ }
+
+ Assert.That(action(value), Is.EqualTo("value"));
+ }
}
}
diff --git a/uml4net.HandleBars.Tests/OperationHelperTestFixture.cs b/uml4net.HandleBars.Tests/OperationHelperTestFixture.cs
index fec9ff0e..6a921003 100644
--- a/uml4net.HandleBars.Tests/OperationHelperTestFixture.cs
+++ b/uml4net.HandleBars.Tests/OperationHelperTestFixture.cs
@@ -22,12 +22,14 @@ namespace uml4net.HandleBars.Tests
{
using System;
using System.Globalization;
+ using System.Linq;
using HandlebarsDotNet;
using NUnit.Framework;
using uml4net.Classification;
+ using uml4net.Extensions;
using uml4net.SimpleClassifiers;
using uml4net.StructuredClassifiers;
@@ -208,5 +210,64 @@ public void Verify_that_WriteForPOCOClass_throws_when_arguments_are_invalid()
Assert.That(() => action(new { operation = 5, owner = new Class() }), Throws.TypeOf());
}
+
+ [Test]
+ public void Verify_that_a_parameter_that_is_named_as_a_keyword_is_escaped()
+ {
+ var owner = new Class { Name = "MyClass" };
+ var operation = CreateOperation(owner, "doSomething");
+ operation.OwnedParameter.Single(x => x.Name == "count").Name = "In";
+
+ var interfaceAction = this.handlebarsContext.Compile("{{ #Operation.WriteForPOCOInterface this }}");
+ var extendAction = this.handlebarsContext.Compile("{{ #Operation.WriteForPOCOExtend this }}");
+ var classAction = this.handlebarsContext.Compile("{{ #Operation.WriteForPOCOClass this.Operation this.Class }}");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(interfaceAction(operation), Does.Match(@"DoSomething\(int\s+@in\)"));
+ Assert.That(extendAction(operation), Does.Match(@", int\s+@in\)"));
+ Assert.That(classAction(new { Operation = operation, Class = owner }), Does.Match(@"DoSomething\(int\s+@in\) => this\.ComputeDoSomethingOperation\(@in\);"));
+ }
+ }
+
+ [Test]
+ [NonParallelizable]
+ public void Verify_that_only_the_parameters_that_are_named_as_a_keyword_are_escaped()
+ {
+ var owner = new Class { Name = "MyClass" };
+ var operation = CreateOperation(owner, "doSomething");
+
+ operation.OwnedParameter.Add(new Parameter { Name = "Ref", Direction = ParameterDirectionKind.In, Type = new PrimitiveType { Name = "Integer", XmiId = "Integer" } });
+ operation.OwnedParameter.Add(new Parameter { Name = "value", Direction = ParameterDirectionKind.In, Type = new PrimitiveType { Name = "Integer", XmiId = "Integer" } });
+
+ var interfaceAction = this.handlebarsContext.Compile("{{ #Operation.WriteForPOCOInterface this }}");
+ var extendAction = this.handlebarsContext.Compile("{{ #Operation.WriteForPOCOExtend this }}");
+ var classAction = this.handlebarsContext.Compile("{{ #Operation.WriteForPOCOClass this.Operation this.Class }}");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(interfaceAction(operation), Does.Match(@"DoSomething\(int\s+count, int\s+@ref, int\s+value\)"));
+ Assert.That(extendAction(operation), Does.Match(@", int\s+count, int\s+@ref, int\s+value\)"));
+ Assert.That(classAction(new { Operation = operation, Class = owner }),
+ Does.Match(@"DoSomething\(int\s+count, int\s+@ref, int\s+value\) => this\.ComputeDoSomethingOperation\(count, @ref, value\);"));
+ }
+
+ try
+ {
+ StringExtensions.RegisterAdditionalCSharpKeywords("value");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(interfaceAction(operation), Does.Match(@"DoSomething\(int\s+count, int\s+@ref, int\s+@value\)"));
+ Assert.That(extendAction(operation), Does.Match(@", int\s+count, int\s+@ref, int\s+@value\)"));
+ Assert.That(classAction(new { Operation = operation, Class = owner }),
+ Does.Match(@"DoSomething\(int\s+count, int\s+@ref, int\s+@value\) => this\.ComputeDoSomethingOperation\(count, @ref, @value\);"));
+ }
+ }
+ finally
+ {
+ StringExtensions.ClearAdditionalCSharpKeywords();
+ }
+ }
}
}
diff --git a/uml4net.HandleBars.Tests/PropertyHelperTestFixture.cs b/uml4net.HandleBars.Tests/PropertyHelperTestFixture.cs
index 235c6581..f80593f8 100644
--- a/uml4net.HandleBars.Tests/PropertyHelperTestFixture.cs
+++ b/uml4net.HandleBars.Tests/PropertyHelperTestFixture.cs
@@ -725,6 +725,56 @@ public void Verify_that_QueryIsCSharpString_and_QueryIsByte_helpers_return_expec
}
}
+ [Test]
+ [NonParallelizable]
+ public void Verify_that_WriteForClass_escapes_the_backing_field_of_a_composite_property_that_is_named_as_a_keyword()
+ {
+ var handlebarsTemplate = this.handlebarsContext.Compile("{{ #Property.WriteForClass this.Property this.Class }}");
+
+ var @class = this.QueryClass("StructuredClassifiers", "Class");
+ var ownedComment = @class.QueryAllProperties().Single(x => x.XmiId == "Element-ownedComment");
+
+ ownedComment.Name = "event";
+ var generatedEvent = handlebarsTemplate(new { Property = ownedComment, Class = @class });
+
+ ownedComment.Name = "class";
+ var generatedClass = handlebarsTemplate(new { Property = ownedComment, Class = @class });
+
+ ownedComment.Name = "type";
+ var generatedType = handlebarsTemplate(new { Property = ownedComment, Class = @class });
+
+ ownedComment.Name = "value";
+ var generatedValue = handlebarsTemplate(new { Property = ownedComment, Class = @class });
+
+ string generatedRegisteredValue;
+
+ try
+ {
+ StringExtensions.RegisterAdditionalCSharpKeywords("value");
+ generatedRegisteredValue = handlebarsTemplate(new { Property = ownedComment, Class = @class });
+ }
+ finally
+ {
+ StringExtensions.ClearAdditionalCSharpKeywords();
+ }
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(generatedEvent, Does.Contain("get => this.@event ??= new ContainerList(this);"), "a keyword that was not escaped before");
+ Assert.That(generatedEvent, Does.Contain("set => this.@event = value;"));
+ Assert.That(generatedEvent, Does.Contain("private IContainerList @event;"));
+ Assert.That(generatedEvent, Does.Contain(""), "the name of the property itself is not a keyword");
+
+ Assert.That(generatedClass, Does.Contain("private IContainerList @class;"));
+
+ Assert.That(generatedType, Does.Contain("get => this.type ??= new ContainerList(this);"));
+ Assert.That(generatedType, Does.Not.Contain("@type"), "type is not a keyword of C#");
+
+ Assert.That(generatedValue, Does.Not.Contain("@value"), "a contextual keyword is not escaped");
+ Assert.That(generatedRegisteredValue, Does.Contain("private IContainerList @value;"), "unless it has been registered");
+ }
+ }
+
[Test]
public void Verify_that_WithClassContext_renders_the_block_with_the_property_and_the_class()
{
diff --git a/uml4net.HandleBars.Tests/StringHelperTestFixture.cs b/uml4net.HandleBars.Tests/StringHelperTestFixture.cs
index 0aa7cd21..e73c00ec 100644
--- a/uml4net.HandleBars.Tests/StringHelperTestFixture.cs
+++ b/uml4net.HandleBars.Tests/StringHelperTestFixture.cs
@@ -26,6 +26,8 @@ namespace uml4net.HandleBars.Tests
using NUnit.Framework;
+ using uml4net.Extensions;
+
[TestFixture]
public class StringHelperTestFixture
{
@@ -75,5 +77,40 @@ public void Verify_that_Length_returns_Expected_result()
Assert.That(length, Is.EqualTo("7"));
}
+
+ [Test]
+ public void Verify_that_EscapeCSharpKeyword_returns_Expected_result()
+ {
+ var action = this.handlebarsContext.Compile("{{ #String.EscapeCSharpKeyword this }}");
+
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(action("class"), Is.EqualTo("@class"));
+ Assert.That(action("ownedComment"), Is.EqualTo("ownedComment"));
+ Assert.That(() => action(5), Throws.TypeOf());
+ Assert.That(() => this.handlebarsContext.Compile("{{ #String.EscapeCSharpKeyword this this }}")("class"), Throws.TypeOf());
+ Assert.That(() => this.handlebarsContext.Compile("{{ #String.EscapeCSharpKeyword }}")("class"), Throws.TypeOf());
+ }
+ }
+
+ [Test]
+ [NonParallelizable]
+ public void Verify_that_EscapeCSharpKeyword_escapes_a_registered_keyword()
+ {
+ var action = this.handlebarsContext.Compile("{{ #String.EscapeCSharpKeyword this }}");
+
+ try
+ {
+ StringExtensions.RegisterAdditionalCSharpKeywords("var");
+
+ Assert.That(action("var"), Is.EqualTo("@var"));
+ }
+ finally
+ {
+ StringExtensions.ClearAdditionalCSharpKeywords();
+ }
+
+ Assert.That(action("var"), Is.EqualTo("var"));
+ }
}
}
\ No newline at end of file
diff --git a/uml4net.HandleBars/NamedElementHelper.cs b/uml4net.HandleBars/NamedElementHelper.cs
index 17d92297..42924fba 100644
--- a/uml4net.HandleBars/NamedElementHelper.cs
+++ b/uml4net.HandleBars/NamedElementHelper.cs
@@ -61,6 +61,38 @@ public static void RegisterNamedElementHelper(this IHandlebars handlebars)
writer.WriteSafeString(nameSpace);
});
+
+ // Writes the C# identifier for a NamedElement: its name, escaped when it is a reserved keyword of C#. The
+ // optional second argument is the casing of the first letter: AsModelled (the default),
+ // UpperCaseFirstLetter or LowerCaseFirstLetter
+ handlebars.RegisterHelper("NamedElement.WriteCSharpIdentifier", (writer, _, arguments) =>
+ {
+ if (arguments.Length != 1 && arguments.Length != 2)
+ {
+ throw new HandlebarsException("{{#NamedElement.WriteCSharpIdentifier}} helper must have one or two arguments");
+ }
+
+ if (!(arguments[0] is INamedElement namedElement))
+ {
+ throw new ArgumentException("{{#NamedElement.WriteCSharpIdentifier}} - the first argument is supposed to be an INamedElement");
+ }
+
+ var casing = CSharpIdentifierCasing.AsModelled;
+
+ if (arguments.Length == 2)
+ {
+ var casingName = arguments[1] as string;
+
+ // Enum.TryParse also accepts a number and a comma separated list, only the names are accepted here
+ if (!Enum.GetNames(typeof(CSharpIdentifierCasing)).Contains(casingName, StringComparer.OrdinalIgnoreCase)
+ || !Enum.TryParse(casingName, true, out casing))
+ {
+ throw new ArgumentException("{{#NamedElement.WriteCSharpIdentifier}} - the second argument is supposed to be AsModelled, UpperCaseFirstLetter or LowerCaseFirstLetter");
+ }
+ }
+
+ writer.WriteSafeString(namedElement.QueryCSharpIdentifier(casing));
+ });
}
}
}
diff --git a/uml4net.HandleBars/OperationHelper.cs b/uml4net.HandleBars/OperationHelper.cs
index dbc3d063..7e295337 100644
--- a/uml4net.HandleBars/OperationHelper.cs
+++ b/uml4net.HandleBars/OperationHelper.cs
@@ -66,7 +66,7 @@ public static void RegisterOperationHelper(this IHandlebars handlebars)
writer.WriteSafeString($" {operation.Name.CapitalizeFirstLetter()}(");
var inputParameters = operation.OwnedParameter.Where(x => x.Direction != ParameterDirectionKind.Return).ToList();
- writer.WriteSafeString(string.Join(", ", inputParameters.Select(x => $"{x.QueryCSharpFullTypeName()} {x.Name.LowerCaseFirstLetter()}")));
+ writer.WriteSafeString(string.Join(", ", inputParameters.Select(x => $"{x.QueryCSharpFullTypeName()} {x.Name.LowerCaseFirstLetter().EscapeCSharpKeyword()}")));
writer.WriteSafeString($");{Environment.NewLine}");
});
@@ -96,7 +96,7 @@ public static void RegisterOperationHelper(this IHandlebars handlebars)
if (inputParameters.Count > 0)
{
- var parametersDefinition = string.Join(", ", inputParameters.Select(x => $"{x.QueryCSharpFullTypeName()} {x.Name.LowerCaseFirstLetter()}"));
+ var parametersDefinition = string.Join(", ", inputParameters.Select(x => $"{x.QueryCSharpFullTypeName()} {x.Name.LowerCaseFirstLetter().EscapeCSharpKeyword()}"));
writer.WriteSafeString($", {parametersDefinition}");
}
@@ -146,9 +146,9 @@ public static void RegisterOperationHelper(this IHandlebars handlebars)
writer.WriteSafeString($"{operation.Name.CapitalizeFirstLetter()}(");
var inputParameters = operation.OwnedParameter.Where(x => x.Direction != ParameterDirectionKind.Return).ToList();
- writer.WriteSafeString(string.Join(", ", inputParameters.Select(x => $"{x.QueryCSharpFullTypeName()} {x.Name.LowerCaseFirstLetter()}")));
+ writer.WriteSafeString(string.Join(", ", inputParameters.Select(x => $"{x.QueryCSharpFullTypeName()} {x.Name.LowerCaseFirstLetter().EscapeCSharpKeyword()}")));
writer.WriteSafeString($") => this.{methodName}(");
- writer.WriteSafeString(string.Join(", ", inputParameters.Select(x => $"{x.Name.LowerCaseFirstLetter()}")));
+ writer.WriteSafeString(string.Join(", ", inputParameters.Select(x => $"{x.Name.LowerCaseFirstLetter().EscapeCSharpKeyword()}")));
writer.WriteSafeString($");{Environment.NewLine}");
});
}
diff --git a/uml4net.HandleBars/PropertyHelper.cs b/uml4net.HandleBars/PropertyHelper.cs
index d01c06da..f66aedcd 100644
--- a/uml4net.HandleBars/PropertyHelper.cs
+++ b/uml4net.HandleBars/PropertyHelper.cs
@@ -730,22 +730,7 @@ public static void RegisterPropertyHelper(this IHandlebars handlebars)
}
else if (property.QueryIsContainment())
{
- propertyName = property.Name;
-
- switch (propertyName)
- {
- case "class":
- case "interface":
- case "namespace":
- case "object":
- case "type":
- case "using":
- propertyName = $"@{propertyName}";
- break;
- default:
- // nothing to do here
- break;
- }
+ propertyName = property.Name.EscapeCSharpKeyword();
sb.AppendLine("{");
diff --git a/uml4net.HandleBars/StringHelper.cs b/uml4net.HandleBars/StringHelper.cs
index 7a9aba21..0fe76b15 100644
--- a/uml4net.HandleBars/StringHelper.cs
+++ b/uml4net.HandleBars/StringHelper.cs
@@ -72,6 +72,22 @@ public static void RegisterStringHelper(this IHandlebars handlebars)
writer.WriteSafeString(value.Length);
});
+
+ // Writes the string, prefixed with @ when it is a reserved keyword of C#
+ handlebars.RegisterHelper("String.EscapeCSharpKeyword", (writer, _, parameters) =>
+ {
+ if (parameters.Length != 1)
+ {
+ throw new HandlebarsException("{{#String.EscapeCSharpKeyword}} helper must have exactly one argument");
+ }
+
+ if (!(parameters[0] is string value))
+ {
+ throw new HandlebarsException("{{#String.EscapeCSharpKeyword}} helper argument must be a string");
+ }
+
+ writer.WriteSafeString(value.EscapeCSharpKeyword());
+ });
}
}
}