From 518974efc01f85df353352071860ea7e2bed5898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Szab=C3=B3?= Date: Wed, 17 Jul 2024 17:05:01 +0200 Subject: [PATCH] Added support for limiting the number of lines shown in the generated comment. Reorganized tests for completeness. Fixed custom item properties not being available to the generator. --- src/EmbeddedTexts/EmbeddedTextsGenerator.cs | 11 +- src/EmbeddedTexts/PodNet.EmbeddedTexts.csproj | 4 +- .../build/PodNet.EmbeddedTexts.props | 3 + .../EmbeddedTextsGeneratorIntegrationTest.cs | 26 ++- .../Files/10Lines.txt | 10 + .../Files/Const.txt | 1 + .../Files/CustomPropertyName.txt | 1 + ...dNet.EmbeddedTexts.IntegrationTests.csproj | 14 +- .../EmbeddedTextGeneratorTests.cs | 172 +++++++++++------- .../PodNet.EmbeddedTexts.Tests.csproj | 7 +- 10 files changed, 162 insertions(+), 87 deletions(-) create mode 100644 tests/EmbeddedTexts.IntegrationTests/Files/10Lines.txt create mode 100644 tests/EmbeddedTexts.IntegrationTests/Files/Const.txt create mode 100644 tests/EmbeddedTexts.IntegrationTests/Files/CustomPropertyName.txt diff --git a/src/EmbeddedTexts/EmbeddedTextsGenerator.cs b/src/EmbeddedTexts/EmbeddedTextsGenerator.cs index c2ff89a..1adb866 100644 --- a/src/EmbeddedTexts/EmbeddedTextsGenerator.cs +++ b/src/EmbeddedTexts/EmbeddedTextsGenerator.cs @@ -13,6 +13,7 @@ public sealed class EmbeddedTextsGenerator : IIncrementalGenerator public const string EmbedTextClassNameMetadataProperty = "PodNet_EmbedTextClassName"; public const string EmbedTextIsConstMetadataProperty = "PodNet_EmbedTextIsConst"; public const string EmbedTextIdentifierMetadataProperty = "PodNet_EmbedTextIdentifier"; + public const string EmbedTextCommentContentLinesMetadataProperty = "PodNet_EmbedTextCommentContentLines"; public record EmbeddedTextItemOptions( string? RootNamespace, @@ -21,6 +22,7 @@ public record EmbeddedTextItemOptions( string? ItemClassName, bool? IsConst, string? Identifier, + uint? CommentContentLines, bool Enabled, AdditionalText Text); @@ -42,6 +44,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ItemClassName: itemOptions.GetAdditionalTextMetadata(EmbedTextClassNameMetadataProperty), IsConst: string.Equals(itemOptions.GetAdditionalTextMetadata(EmbedTextIsConstMetadataProperty), "true", StringComparison.OrdinalIgnoreCase), Identifier: itemOptions.GetAdditionalTextMetadata(EmbedTextIdentifierMetadataProperty), + CommentContentLines: uint.TryParse(itemOptions.GetAdditionalTextMetadata(EmbedTextCommentContentLinesMetadataProperty), out var commentLines) ? commentLines : null, Enabled: (globalEnabled || itemEnabled) && !itemDisabled, Text: text); }); @@ -94,15 +97,17 @@ public static partial class {{className}} /// """); - foreach (var line in lines.Take(10)) + var commentLines = (int)item.CommentContentLines.GetValueOrDefault(); + + foreach (var line in commentLines > 0 ? lines.Take(commentLines) : lines) { sourceBuilder.AppendLine($$""" /// {{line.ToString().Replace("<", "<").Replace(">", ">")}} """); } - if (lines.Count > 10) + if (commentLines > 0 && lines.Count > commentLines) { - sourceBuilder.AppendLine($"/// [{lines.Count - 10} more lines ({lines.Count} total)] "); + sourceBuilder.AppendLine($"/// [{lines.Count - commentLines} more lines ({lines.Count} total)] "); } sourceBuilder.AppendLine($$""" diff --git a/src/EmbeddedTexts/PodNet.EmbeddedTexts.csproj b/src/EmbeddedTexts/PodNet.EmbeddedTexts.csproj index 35eef22..cd02d94 100644 --- a/src/EmbeddedTexts/PodNet.EmbeddedTexts.csproj +++ b/src/EmbeddedTexts/PodNet.EmbeddedTexts.csproj @@ -8,9 +8,7 @@ - - - + diff --git a/src/EmbeddedTexts/build/PodNet.EmbeddedTexts.props b/src/EmbeddedTexts/build/PodNet.EmbeddedTexts.props index e287d49..f6b3cdf 100644 --- a/src/EmbeddedTexts/build/PodNet.EmbeddedTexts.props +++ b/src/EmbeddedTexts/build/PodNet.EmbeddedTexts.props @@ -8,5 +8,8 @@ + + + \ No newline at end of file diff --git a/tests/EmbeddedTexts.IntegrationTests/EmbeddedTextsGeneratorIntegrationTest.cs b/tests/EmbeddedTexts.IntegrationTests/EmbeddedTextsGeneratorIntegrationTest.cs index 4f5e582..091f54d 100644 --- a/tests/EmbeddedTexts.IntegrationTests/EmbeddedTextsGeneratorIntegrationTest.cs +++ b/tests/EmbeddedTexts.IntegrationTests/EmbeddedTextsGeneratorIntegrationTest.cs @@ -1,11 +1,17 @@ -namespace PodNet.EmbeddedTexts.IntegrationTests; +using PodNet.Analyzers.Testing.CSharp; + +namespace PodNet.EmbeddedTexts.IntegrationTests; /// /// This tests that, given the provided files are correctly added to AdditionaFiles, the correct /// classes are generated in the correct namespaces with the correct API surface (property), and it returns -/// the file content as the result (unless ignored). These "tests" won't even compile if the correct +/// the file content as the result (unless ignored). Most of these "tests" won't even compile if the correct /// APIs are not generated. /// +/// +/// Note that testing comments in integrated scenarios is not possible, due to the comments +/// only being available to the IDE and not the runtime. Unit tests can be used to test them. +/// [TestClass] public class EmbeddedTextsGeneratorIntegrationTest { @@ -24,17 +30,17 @@ public void IgnoredContentIsNotEmbedded() // Given that the Files\Ignored\** pattern is excluded by setting "PodNet_EmbedText" to "false", the following shouldn't compile: // Files.Ignored.Ignored_txt.Content; var undefined = "PodNet.EmbeddedTexts.IntegrationTests.Files.Ignored.Ignored_txt"; - var compilation = Analyzers.Testing.CSharp.PodCSharpCompilation.Create([$$""" + var compilation = PodCSharpCompilation.Create([$$""" class ShouldError { string WontCompile() => {{undefined}}.Content; }; """]); - var diagnostics = compilation.CurrentCompilation.GetDiagnostics(); + var diagnostics = compilation.GetDiagnostics(); Assert.IsTrue(diagnostics.Any(d => d is { Severity: Microsoft.CodeAnalysis.DiagnosticSeverity.Error, - Id: "CS0234", // {The type or namespace name '{0}' does not exist in the namespace '{1}' (are you missing an assembly reference?)} + Id: "CS0234", // The type or namespace name '{0}' does not exist in the namespace '{1}' (are you missing an assembly reference?) Location: { SourceSpan: var span, SourceTree: var tree } } && tree?.GetText().GetSubText(span).ToString() == undefined)); } @@ -52,4 +58,14 @@ public void CustomClassAndNamespaceNamesCanBeSupplied() Assert.IsNotNull(TestNamespace.TestSubNamespace.CustomNamespace_txt.Content); Assert.IsNotNull(TestNamespace.TestSubNamespace.TestClass.Content); } + + [TestMethod] + public void ConstAndPropertyCanBeCustomized() + { + // The following won't compile if the symbol is a property instead of a const. + const string? content = Files.Const_txt.Content; + Assert.IsNotNull(content); + + Assert.IsNotNull(Files.CustomPropertyName_txt.TestProperty); + } } diff --git a/tests/EmbeddedTexts.IntegrationTests/Files/10Lines.txt b/tests/EmbeddedTexts.IntegrationTests/Files/10Lines.txt new file mode 100644 index 0000000..439c41e --- /dev/null +++ b/tests/EmbeddedTexts.IntegrationTests/Files/10Lines.txt @@ -0,0 +1,10 @@ +Line 1 +Line 2 +Line 3 +Line 4 +Line 5 +Line 6 +Line 7 +Line 8 +Line 9 +Line 10 \ No newline at end of file diff --git a/tests/EmbeddedTexts.IntegrationTests/Files/Const.txt b/tests/EmbeddedTexts.IntegrationTests/Files/Const.txt new file mode 100644 index 0000000..793cd2a --- /dev/null +++ b/tests/EmbeddedTexts.IntegrationTests/Files/Const.txt @@ -0,0 +1 @@ +Const \ No newline at end of file diff --git a/tests/EmbeddedTexts.IntegrationTests/Files/CustomPropertyName.txt b/tests/EmbeddedTexts.IntegrationTests/Files/CustomPropertyName.txt new file mode 100644 index 0000000..66044a8 --- /dev/null +++ b/tests/EmbeddedTexts.IntegrationTests/Files/CustomPropertyName.txt @@ -0,0 +1 @@ +CustomPropertyName \ No newline at end of file diff --git a/tests/EmbeddedTexts.IntegrationTests/PodNet.EmbeddedTexts.IntegrationTests.csproj b/tests/EmbeddedTexts.IntegrationTests/PodNet.EmbeddedTexts.IntegrationTests.csproj index 9160ae8..9952611 100644 --- a/tests/EmbeddedTexts.IntegrationTests/PodNet.EmbeddedTexts.IntegrationTests.csproj +++ b/tests/EmbeddedTexts.IntegrationTests/PodNet.EmbeddedTexts.IntegrationTests.csproj @@ -6,10 +6,9 @@ - - - - + + + @@ -20,12 +19,17 @@ + + - + + + + diff --git a/tests/EmbeddedTexts.Tests/EmbeddedTextGeneratorTests.cs b/tests/EmbeddedTexts.Tests/EmbeddedTextGeneratorTests.cs index 3513476..a7f0264 100644 --- a/tests/EmbeddedTexts.Tests/EmbeddedTextGeneratorTests.cs +++ b/tests/EmbeddedTexts.Tests/EmbeddedTextGeneratorTests.cs @@ -15,7 +15,7 @@ public class EmbeddedTextGeneratorTests [TestMethod] public void DoesntGenerateWhenDisabled() { - var result = RunGeneration(false, false); + var result = RunGeneration(GetGlobalOptions(false), FakeText.Default); Assert.AreEqual(1, result.Results.Length); Assert.AreEqual(0, result.Results[0].GeneratedSources.Length); } @@ -23,41 +23,33 @@ public void DoesntGenerateWhenDisabled() [TestMethod] public void GeneratesForOptInOnly() { - var result = RunGeneration(false, true); + var result = RunGeneration(GetGlobalOptions(false), FakeText.Default, FakeText.Enabled, FakeText.Disabled); - Assert.IsTrue(result.Results.Single().GeneratedSources.Single().SyntaxTree.ToString().Contains("Test File 2 Content")); + Assert.IsTrue(result.Results.Single().GeneratedSources.Single().SyntaxTree.ToString().Contains("Enabled Content")); } [TestMethod] public void DoesntGenerateForOptOut() { - var result = RunGeneration(true, false); + var result = RunGeneration(GetGlobalOptions(true), FakeText.Default, FakeText.Enabled, FakeText.Disabled); Assert.AreEqual(1, result.Results.Length); - Assert.AreEqual(7, result.Results[0].GeneratedSources.Length); - } - - [TestMethod] - public void GenerateAllWhenGloballyEnabledAndItemIsOptIn() - { - var result = RunGeneration(true, true); - Assert.AreEqual(1, result.Results.Length); - Assert.AreEqual(8, result.Results[0].GeneratedSources.Length); + Assert.AreEqual(2, result.Results[0].GeneratedSources.Length); } [TestMethod] public void GeneratesStructurallyEquivalentResult() { // Generates a single source as per GeneratesForOptInOnly - var result = RunGeneration(false, true); + var result = RunGeneration(FakeText.Default); var source = result.Results.Single().GeneratedSources.Single(); var expected = CSharpSyntaxTree.ParseText("""" namespace Project; - public static partial class Parameterized_Enabled_cs + public static partial class Default_txt { public static string Content => """ - Test File 2 Content + Default Content """; } """").GetRoot(); @@ -66,74 +58,120 @@ Test File 2 Content } [DataTestMethod] - [DataRow("Default_txt", "Project")] - [DataRow("Parameterized_Enabled_cs", "Project")] - [DataRow("CustomNamespace_n", "TestNamespace")] - [DataRow("TestClassName", "Project")] - [DataRow("Empty", "Project")] - [DataRow("Empty_ini", "Project.Subdirectory._2_Another____Subdirectory")] - public void GeneratesCorrectClassNamesAndNamespaces(string expectedClassName, string expectedNamespace) + [DataRow($@"{ProjectRoot}//Default.txt", null, null, "Project", "Default_txt")] + [DataRow($@"{ProjectRoot}//Subdirectory//2 Another & Subdirectory/File | Weird.ini", null, null, "Project.Subdirectory._2_Another____Subdirectory", "File___Weird_ini")] + [DataRow($@"{ProjectRoot}//CustomNamespace.txt", "TestCustomNamespace", null, "TestCustomNamespace", "CustomNamespace_txt")] + [DataRow($@"{ProjectRoot}//CustomClassName.txt", null, "TestCustomClassName", "Project", "TestCustomClassName")] + [DataRow($@"{ProjectRoot}//CustomNamespaceAndClass.txt", "TestCustomNamespace", "TestCustomClassName", "TestCustomNamespace", "TestCustomClassName")] + public void GeneratesCorrectNamespacesAndClassNames(string path, string? namespaceProperty, string? classProperty, string expectedNamespace, string expectedClassName) { - var result = RunGeneration(true, true); - var source = result.Results.Single().GeneratedSources.SingleOrDefault(s => s.HintName.EndsWith($"{expectedClassName}.g.cs")); - Assert.IsNotNull(source); - var @namespace = source.SyntaxTree.GetRoot().DescendantNodes().OfType().FirstOrDefault()?.Name.ToString(); - var className = source.SyntaxTree.GetRoot().DescendantNodes().OfType().FirstOrDefault()?.Identifier.ToString(); + var options = new Dictionary(); + if (namespaceProperty != null) + options[$"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextNamespaceMetadataProperty}"] = namespaceProperty; + if (classProperty != null) + options[$"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextClassNameMetadataProperty}"] = classProperty; + var result = RunGeneration(new FakeText(path, $"{path} Contents", options)); + var sourceNodes = result.Results.Single().GeneratedSources.Single().SyntaxTree.GetRoot().DescendantNodes(); + var @namespace = sourceNodes.OfType().FirstOrDefault()?.Name.ToString(); + var className = sourceNodes.OfType().FirstOrDefault()?.Identifier.ToString(); Assert.AreEqual(expectedNamespace, @namespace); Assert.AreEqual(expectedClassName, className); } [DataTestMethod] - [DataRow("Default_txt", "public static string Content => ")] - [DataRow("Const", "public const string Content = ")] - [DataRow("CustomIdentifier", "public static string CustomIdentifier => ")] - public void GeneratesConstantsAndCustomIdentifiersOnDemand(string expectedClassName, string expectedDeclaration) + [DataRow(null, null, "public static string Content => ")] + [DataRow(false, null, "public static string Content => ")] + [DataRow(true, null, "public const string Content = ")] + [DataRow(null, "CustomIdentifier", "public static string CustomIdentifier => ")] + [DataRow(true, "CustomIdentifier", "public const string CustomIdentifier = ")] + public void GeneratesConstantsAndCustomIdentifiersOnDemand(bool? constantConfigValue, string? customIdentifier, string expectedDeclaration) { - var result = RunGeneration(true, true); - - var source = result.Results.Single().GeneratedSources.SingleOrDefault(s => s.HintName.EndsWith($"{expectedClassName}.g.cs")); - Assert.IsNotNull(source); + var options = new Dictionary(); + if (constantConfigValue is not null) + options[$"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextIsConstMetadataProperty}"] = constantConfigValue.ToString(); + if (customIdentifier is not null) + options[$"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextIdentifierMetadataProperty}"] = customIdentifier; + var result = RunGeneration(new FakeText($@"{ProjectRoot}//File", "Contents", options)); + var source = result.Results.Single().GeneratedSources.Single(); var actualDeclaration = source.SyntaxTree.GetRoot().DescendantNodes().OfType().SingleOrDefault()?.DescendantNodes().OfType().SingleOrDefault()?.ToString(); - Assert.AreEqual(expectedDeclaration, actualDeclaration?[0..expectedDeclaration.Length]); + Assert.IsNotNull(actualDeclaration); + Assert.AreEqual(expectedDeclaration, actualDeclaration[0..expectedDeclaration.Length]); + } + + [DataTestMethod] + [DataRow(10, 4, 5)] + [DataRow(10, 15, 10)] + [DataRow(10, -15, 10)] + [DataRow(1000, 50, 51)] + [DataRow(1000, null, 1000)] + public void GeneratesCustomCommentLines(int fileContentLines, int? limitLineNumber, int expectedLines) + { + if (limitLineNumber is < 0) + limitLineNumber = null; + var options = new Dictionary(); + if (limitLineNumber is not null) + options[$"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextCommentContentLinesMetadataProperty}"] = limitLineNumber.ToString(); + var lines = Enumerable.Range(1, fileContentLines).Select(n => $"Line [{n}]").ToList(); + var result = RunGeneration(new FakeText($@"{ProjectRoot}//File", string.Join("\n", lines), options)); + + var source = result.Results.Single().GeneratedSources.Single(); + var sourceLines = source.SyntaxTree.ToString().Split(["\r\n", "\r", "\n"], StringSplitOptions.TrimEntries); + var commentCodeLines = sourceLines.SkipWhile(s => s != "/// ").Skip(1).TakeWhile(s => s != "/// ").ToList(); + + if (fileContentLines > 0) + Assert.IsTrue(commentCodeLines[0] == "/// Line [1]"); + Assert.AreEqual(expectedLines, commentCodeLines.Count); + if (limitLineNumber is not null && fileContentLines > limitLineNumber) + { + Assert.IsTrue(commentCodeLines[^2] == $"/// Line [{limitLineNumber}]"); + Assert.IsTrue(commentCodeLines[^1] == $"/// [{fileContentLines - limitLineNumber} more lines ({fileContentLines} total)]"); + } } - public static GeneratorDriverRunResult RunGeneration(bool globalEnabled, bool oneItemEnabled) + private static IIncrementalGenerator[] Generators { get; } = [new EmbeddedTextsGenerator()]; + private static CSharpCompilation Compilation { get; } = PodCSharpCompilation.Create([]); + + private static GeneratorDriverRunResult RunGeneration(Fakes.AnalyzerConfigOptions globalOptions, params FakeText[] additionalTexts) { - var additionalTextsLookup = GetOptionsForTexts(oneItemEnabled) - .ToDictionary(f => (AdditionalText)f.Key, f => f.Value); - var globalOptions = GetGlobalOptions(globalEnabled); - var optionsProvider = new Fakes.AnalyzerConfigOptionsProvider(globalOptions, [], additionalTextsLookup); - var compilation = PodCSharpCompilation.Create([]); - var generator = new EmbeddedTextsGenerator(); - return compilation.RunGenerators( - [generator], + var additionalTextsDictionary = additionalTexts.Select(e => (Text: (AdditionalText)new Fakes.AdditionalText(e.Path, e.Contents), e.Options)).ToDictionary(e => e.Text, e => new Fakes.AnalyzerConfigOptions(e.Options ?? [])); + return Compilation.RunGenerators( + Generators, driver => (CSharpGeneratorDriver)driver - .AddAdditionalTexts(additionalTextsLookup.Select(a => a.Key).ToImmutableArray()) - .WithUpdatedAnalyzerConfigOptions(optionsProvider)); + .AddAdditionalTexts([.. additionalTextsDictionary.Keys]) + .WithUpdatedAnalyzerConfigOptions(new Fakes.AnalyzerConfigOptionsProvider(globalOptions, [], additionalTextsDictionary))); } - private static Fakes.AnalyzerConfigOptions GetGlobalOptions(bool globalEnabled) => new() + private static GeneratorDriverRunResult RunGeneration(params FakeText[] additionalTexts) + => RunGeneration(NoOptions, additionalTexts); + + private static Fakes.AnalyzerConfigOptions NoOptions { get; } = new() { ["build_property.rootnamespace"] = "Project", ["build_property.projectdir"] = ProjectRoot, - [$"build_property.{EmbeddedTextsGenerator.EmbedAdditionalTextsConfigProperty}"] = globalEnabled.ToString() }; - private static Dictionary GetOptionsForTexts(bool oneItemEnabled) => new() + private static Fakes.AnalyzerConfigOptions GetGlobalOptions(Dictionary additionalValues) { - [new($@"{ProjectRoot}//Default.txt", "Test File 1 Content")] - = [], - [new($@"{ProjectRoot}//Parameterized Enabled.cs", "Test File 2 Content")] - = new() { [$"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextMetadataProperty}"] = oneItemEnabled.ToString() }, - [new($@"{ProjectRoot}//CustomNamespace.n", "Test File 3 Content")] - = new() { [$"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextNamespaceMetadataProperty}"] = "TestNamespace" }, - [new($@"{ProjectRoot}//CustomClassName.n", "Test File 4 Content")] - = new() { [$"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextClassNameMetadataProperty}"] = "TestClassName" }, - [new($@"{ProjectRoot}//Empty", "")] = [], - [new($@"{ProjectRoot}//Subdirectory//2 Another & Subdirectory/Empty.ini", "")] = [], - [new($@"{ProjectRoot}//Const", "")] - = new () { [$"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextIsConstMetadataProperty}"] = "true" }, - [new($@"{ProjectRoot}//CustomIdentifier", "")] - = new() { [$"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextIdentifierMetadataProperty}"] = "CustomIdentifier" }, - }; -} \ No newline at end of file + var options = new Fakes.AnalyzerConfigOptions(NoOptions.Values); + foreach (var value in additionalValues) + options.Add(value); + return options; + } + + private static Fakes.AnalyzerConfigOptions GetGlobalOptions(bool enabled) + => GetGlobalOptions(additionalValues: new() + { + [$"build_property.{EmbeddedTextsGenerator.EmbedAdditionalTextsConfigProperty}"] = enabled.ToString() + }); + + private record class FakeText(string Path, string Contents, Dictionary? Options = null) + { + public FakeText(string Path, string Contents, params (string Key, string? Value)[] Options) : this(Path, Contents, Options.ToDictionary(e => e.Key, e => e.Value)) { } + + public static FakeText Default { get; } = new($@"{ProjectRoot}//Default.txt", "Default Content"); + public static FakeText Enabled { get; } = new($@"{ProjectRoot}//Enabled.txt", "Enabled Content", ($"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextMetadataProperty}", "true")); + public static FakeText Disabled { get; } = new($@"{ProjectRoot}//Disabled.txt", "Disabled Content", ($"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextMetadataProperty}", "false")); + + public static FakeText[] GetDefaultItems() => [Default, Enabled, Disabled]; + } +} diff --git a/tests/EmbeddedTexts.Tests/PodNet.EmbeddedTexts.Tests.csproj b/tests/EmbeddedTexts.Tests/PodNet.EmbeddedTexts.Tests.csproj index 5627f9d..3b2fa86 100644 --- a/tests/EmbeddedTexts.Tests/PodNet.EmbeddedTexts.Tests.csproj +++ b/tests/EmbeddedTexts.Tests/PodNet.EmbeddedTexts.Tests.csproj @@ -5,10 +5,9 @@ - - - - + + +