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()); + }); } } }