diff --git a/standard/classes.md b/standard/classes.md index c4ba80bda..c5f5cf566 100644 --- a/standard/classes.md +++ b/standard/classes.md @@ -68,6 +68,7 @@ When a non-abstract class is derived from an abstract class, the non-abstract cl > *Example*: In the following code > +> > ```csharp > abstract class A > { @@ -186,6 +187,7 @@ When a *class_type* is included in the *class_base*, it specifies the direct bas > *Example*: In the following code > +> > ```csharp > class A {} > class B : A {} @@ -199,6 +201,7 @@ For a constructed class type, including a nested type declared within a generic > *Example*: Given the generic class declarations > +> > ```csharp > class B {...} > class G : B {...} @@ -212,17 +215,19 @@ The base class specified in a class declaration can be a constructed class type > *Example*: > +> +> > ```csharp > class Base {} > > // Valid, non-constructed class with constructed base class -> class Extend : Base +> class Extend1 : Base {} > > // Error, type parameter used as base class -> class Extend : V {} +> class Extend2 : V {} > > // Valid, type parameter used as type argument for base class -> class Extend : Base {} +> class Extend3 : Base {} > ``` > > *end example* @@ -235,6 +240,7 @@ In determining the meaning of the direct base class specification `A` of a clas > *Example*: The following > +> > ```csharp > class X > { @@ -252,6 +258,7 @@ The base classes of a class are the direct base class and its base classes. In o > *Example*: In the following: > +> > ```csharp > class A {...} > class B : A {...} @@ -269,12 +276,14 @@ It is a compile-time error for a class to depend on itself. For the purpose of t > *Example*: The example > +> > ```csharp -> class A: A {} +> class A : A {} > ``` > > is erroneous because the class depends on itself. Likewise, the example > +> > ```csharp > class A : B {} > class B : C {} @@ -283,6 +292,7 @@ It is a compile-time error for a class to depend on itself. For the purpose of t > > is in error because the classes circularly depend on themselves. Finally, the example > +> > ```csharp > class A : B.C {} > class B : A @@ -299,6 +309,7 @@ A class does not depend on the classes that are nested within it. > *Example*: In the following code > +> > ```csharp > class A > { @@ -313,6 +324,7 @@ A class does not depend on the classes that are nested within it. It is not possible to derive from a sealed class. > *Example*: In the following code > +> > ```csharp > sealed class A {} > class B : A {} // Error, cannot derive from a sealed class @@ -843,6 +855,7 @@ All members of a generic class can use type parameters from any enclosing class, > *Example*: > +> > ```csharp > class C > { @@ -1277,6 +1290,8 @@ Both signatures are reserved, even if the property is read-only or write-only. > *Example*: In the following code > +> +> > ```csharp > using System; > class A diff --git a/tools/ExampleExtractor/Example.cs b/tools/ExampleExtractor/Example.cs new file mode 100644 index 000000000..ed7184ca0 --- /dev/null +++ b/tools/ExampleExtractor/Example.cs @@ -0,0 +1,133 @@ +using Newtonsoft.Json; +using System.Net.Http.Json; + +namespace ExampleExtractor; + +internal class Example +{ + private const string ExampleCommentPrefix = ""; + + internal ExampleMetadata Metadata { get; } + + /// + /// The name of the example. This should be unique across all files. + /// + internal string Name => Metadata.Name; + + /// + /// The name of the template to apply. + /// + internal string Template => Metadata.Template; + + /// + /// The source location of the example. + /// + internal string Source => Metadata.Source; + + /// + /// The code within the example. + /// + internal string Code { get; } + + /// + /// Loads examples from all the Markdown files in the given directory. + /// + internal static List LoadExamplesFromDirectory(string directory) => + Directory.GetFiles(directory, "*.md") + .SelectMany(LoadExamplesFromFile) + .ToList(); + + private Example(ExampleMetadata metadata, string code) + { + Metadata = metadata; + if (metadata.ReplaceEllipsis) + { + code = code.Replace("...", "/* ... */"); + } + Code = code; + } + + /// + /// Loads examples from a single Markdown file. + /// + private static IEnumerable LoadExamplesFromFile(string markdownFile) + { + string[] lines = File.ReadAllLines(markdownFile); + + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i]; + if (!line.Contains(ExampleCommentPrefix)) + { + continue; + } + var metadata = ParseComment(line); + + string prefix = line.Substring(0, line.IndexOf(ExampleCommentPrefix)); + string trimmedPrefix = prefix.Trim(); + + // We don't currently assume the example comes immediately after the comment. + // This could allow for expected output in another comment, for example. + // If it turns out not to be useful, we could just check that lines[i+1] ends with ```csharp + int openingLine = FindLineEnding(i, "```csharp"); // 0-based, and pre-code + int closingLine = FindLineEnding(openingLine, "```"); // 0-based, and post-code + + var codeLines = lines + .Skip(openingLine + 1) + .Take(closingLine - openingLine - 1) + .Select(TrimPrefix); + + string code = string.Join("\n", codeLines); + + // Augment the metadata + metadata.StartLine = openingLine + 1; + metadata.EndLine = closingLine; + metadata.MarkdownFile = Path.GetFileName(markdownFile); + + yield return new Example(metadata, code); + i = closingLine; + + string TrimPrefix(string codeLine) => + codeLine.StartsWith(prefix) ? codeLine.Substring(prefix.Length) + : codeLine.StartsWith(trimmedPrefix) ? codeLine.Substring(trimmedPrefix.Length) + : throw new InvalidOperationException($"Example in {markdownFile} starting at line {openingLine} contains line without common prefix"); + + } + + int FindLineEnding(int start, string suffix) + { + for (int i = start; i < lines.Length; i++) + { + if (lines[i].EndsWith(suffix)) + { + return i; + } + } + throw new InvalidOperationException($"File {markdownFile} has no line ending '{suffix}' starting at line {start + 1}"); + } + + ExampleMetadata ParseComment(string commentLine) + { + int prefixIndex = commentLine.IndexOf(ExampleCommentPrefix); + if (prefixIndex == -1) + { + throw new ArgumentException($"'{commentLine}' does not contain {ExampleCommentPrefix}"); + } + if (!commentLine.EndsWith(CommentSuffix)) + { + throw new ArgumentException($"'{commentLine}' does not end with {CommentSuffix}"); + } + string json = commentLine[(prefixIndex + ExampleCommentPrefix.Length)..^CommentSuffix.Length]; + try + { + return JsonConvert.DeserializeObject(json) ?? throw new ArgumentException("Invalid (null) configuration"); + } + catch (JsonException e) + { + // TODO: Add the source information as well. + throw new Exception($"Error parsing metadata '{json}'", e); + } + } + } +} diff --git a/tools/ExampleExtractor/ExampleExtractor.csproj b/tools/ExampleExtractor/ExampleExtractor.csproj new file mode 100644 index 000000000..11b28f3a8 --- /dev/null +++ b/tools/ExampleExtractor/ExampleExtractor.csproj @@ -0,0 +1,14 @@ + + + + Exe + net6.0 + enable + enable + + + + + + + diff --git a/tools/ExampleExtractor/ExampleMetadata.cs b/tools/ExampleExtractor/ExampleMetadata.cs new file mode 100644 index 000000000..c719b1d49 --- /dev/null +++ b/tools/ExampleExtractor/ExampleMetadata.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +#nullable disable + +namespace ExampleExtractor; + +/// +/// Metadata about an example, from the configuration in the Markdown comment +/// +public class ExampleMetadata +{ + public const string MetadataFile = "metadata.json"; + + // Information loaded from the comment + public string Template { get; set; } + public string Name { get; set; } + public bool ReplaceEllipsis { get; set; } + public List ExpectedErrors { get; set; } + public List ExpectedWarnings { get; set; } + public List ExpectedOutput { get; set; } + + // Information provided by the example extractor + public string MarkdownFile { get; set; } + public int StartLine { get; set; } + public int EndLine { get; set; } + + [JsonIgnore] + public string Source => $"{MarkdownFile}:{StartLine}-{EndLine}"; +} diff --git a/tools/ExampleExtractor/Program.cs b/tools/ExampleExtractor/Program.cs new file mode 100644 index 000000000..e93c17398 --- /dev/null +++ b/tools/ExampleExtractor/Program.cs @@ -0,0 +1,69 @@ +using ExampleExtractor; + +if (args.Length != 3) +{ + Console.WriteLine("Arguments: "); + return 1; +} + +string markdownDirectory = args[0]; +string templateDirectory = args[1]; +string outputDirectory = args[2]; + +if (!ValidateDirectory(markdownDirectory) || + !ValidateDirectory(templateDirectory)) +{ + return 1; +} + +if (Directory.Exists(outputDirectory)) +{ + if (Directory.GetFiles(outputDirectory).Any()) + { + Console.WriteLine($"Error: {outputDirectory} exists and contains files."); + } + var oldSubdirectories = Directory.GetDirectories(outputDirectory); + if (oldSubdirectories.Any()) + { + Console.WriteLine($"Deleting old output subdirectories ({oldSubdirectories.Length})"); + foreach (var subdirectory in oldSubdirectories) + { + Directory.Delete(subdirectory, true); + } + } +} +else +{ + Directory.CreateDirectory(outputDirectory); +} + +var templates = Template.LoadTemplates(templateDirectory); +Console.WriteLine($"Loaded {templates.Count} templates"); +var examples = Example.LoadExamplesFromDirectory(markdownDirectory); +Console.WriteLine($"Loaded {examples.Count} examples"); + +bool anyErrors = false; +foreach (var example in examples) +{ + Console.WriteLine($"Processing example {example.Name} from {example.Source}"); + if (!templates.TryGetValue(example.Template, out var template)) + { + Console.WriteLine($"ERROR: template '{example.Template}' not found"); + anyErrors = true; + continue; + } + template.Apply(example, outputDirectory); +} +Console.WriteLine("Finished example extraction."); + +return anyErrors ? 1 : 0; + +bool ValidateDirectory(string directory) +{ + if (!Directory.Exists(directory)) + { + Console.WriteLine($"Error: '{directory}' does not exist or is not a directory"); + return false; + } + return true; +} \ No newline at end of file diff --git a/tools/ExampleExtractor/Template.cs b/tools/ExampleExtractor/Template.cs new file mode 100644 index 000000000..31786c43c --- /dev/null +++ b/tools/ExampleExtractor/Template.cs @@ -0,0 +1,52 @@ +using Newtonsoft.Json; + +namespace ExampleExtractor; + +internal class Template +{ + private const string ExampleCodeSubstitution = "$example-code"; + private const string ExampleNameSubstitution = "$example-name"; + + internal string Name { get; } + private readonly Dictionary files; + + private Template(string name, Dictionary files) + { + Name = name; + this.files = files; + } + + /// + /// Applies the given example to this template, writing it out to an output directory. + /// + /// The example to apply. + /// The root output directory. (A subdirectory for the example will be created within this.) + internal void Apply(Example example, string rootOutputDirectory) + { + var outputDirectory = Path.Combine(rootOutputDirectory, example.Name); + Directory.CreateDirectory(outputDirectory); + foreach (var pair in files) + { + string file = Path.Combine(outputDirectory, pair.Key); + string code = pair.Value + .Replace(ExampleCodeSubstitution, example.Code) + .Replace(ExampleNameSubstitution, example.Name); + File.WriteAllText(file, code); + } + var metadataJson = JsonConvert.SerializeObject(example.Metadata); + File.WriteAllText(Path.Combine(outputDirectory, ExampleMetadata.MetadataFile), metadataJson); + } + + internal static Dictionary LoadTemplates(string directory) => + Directory.GetDirectories(directory) + .Select(LoadTemplate) + .ToDictionary(template => template.Name); + + private static Template LoadTemplate(string directory) + { + var name = Path.GetFileName(directory); + var files = Directory.GetFiles(directory) + .ToDictionary(file => Path.GetFileName(file), file => File.ReadAllText(file)); + return new Template(name, files); + } +} diff --git a/tools/ExampleTester/ExampleTester.csproj b/tools/ExampleTester/ExampleTester.csproj new file mode 100644 index 000000000..e4bd11d71 --- /dev/null +++ b/tools/ExampleTester/ExampleTester.csproj @@ -0,0 +1,20 @@ + + + + Exe + net6.0 + enable + enable + + + + + + + + + + + + + diff --git a/tools/ExampleTester/GeneratedExample.cs b/tools/ExampleTester/GeneratedExample.cs new file mode 100644 index 000000000..7c0d3c0a6 --- /dev/null +++ b/tools/ExampleTester/GeneratedExample.cs @@ -0,0 +1,124 @@ +using ExampleExtractor; +using Microsoft.Build.Locator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.MSBuild; +using Newtonsoft.Json; +using System.Diagnostics; +using System.Reflection; +using System.Text; + +namespace ExampleTester; + +internal class GeneratedExample +{ + static GeneratedExample() + { + MSBuildLocator.RegisterDefaults(); + } + + private readonly string directory; + internal ExampleMetadata Metadata { get; } + + private GeneratedExample(string directory) + { + this.directory = directory; + string metadataJson = File.ReadAllText(Path.Combine(directory, ExampleMetadata.MetadataFile)); + Metadata = JsonConvert.DeserializeObject(metadataJson) ?? throw new ArgumentException($"Invalid (null) metadata in {directory}"); + } + + internal static List LoadAllExamples(string parentDirectory) => + Directory.GetDirectories(parentDirectory).Select(Load).ToList(); + + private static GeneratedExample Load(string directory) + { + return new GeneratedExample(directory); + } + + internal async Task Test() + { + Console.WriteLine($"Testing {Metadata.Name} from {Metadata.Source}"); + + using var workspace = MSBuildWorkspace.Create(); + // TODO: Validate this more cleanly. + var projectFile = Directory.GetFiles(directory, "*.csproj").Single(); + var project = await workspace.OpenProjectAsync(projectFile); + var compilation = await project.GetCompilationAsync(); + if (compilation is null) + { + throw new InvalidOperationException("Project has no Compilation"); + } + + bool ret = true; + ret &= ValidateDiagnostics("errors", DiagnosticSeverity.Error, Metadata.ExpectedErrors); + ret &= ValidateDiagnostics("warnings", DiagnosticSeverity.Warning, Metadata.ExpectedWarnings); + ret &= ValidateOutput(); + + return ret; + + bool ValidateDiagnostics(string type, DiagnosticSeverity severity, List expected) + { + expected ??= new List(); + var actual = compilation.GetDiagnostics().Where(d => d.Severity == severity).Select(d => d.Id).ToList(); + return ValidateExpectedAgainstActual(type, expected, actual); + } + + bool ValidateOutput() + { + var entryPoint = compilation.GetEntryPoint(cancellationToken: default); + if (entryPoint is null) + { + if (Metadata.ExpectedOutput != null) + { + Console.WriteLine(" Output expected, but project has no entry point."); + return false; + } + return true; + } + + string typeName = entryPoint.ContainingType.MetadataName; + string methodName = entryPoint.MetadataName; + + var ms = new MemoryStream(); + var emitResult = compilation.Emit(ms); + if (!emitResult.Success) + { + Console.WriteLine(" Failed to emit assembly"); + return false; + } + + var generatedAssembly = Assembly.Load(ms.ToArray()); + // TODO: Check for null here and below + var type = generatedAssembly.GetType(typeName)!; + var method = type.GetMethod(methodName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)!; + // TODO: Handle async entry points. (Is the entry point the synthesized one, or the user code?) + var arguments = method.GetParameters().Any() ? new object[] { new string[0] } : new object[0]; + + var oldOut = Console.Out; + List actualLines; + try + { + var builder = new StringBuilder(); + Console.SetOut(new StringWriter(builder)); + method.Invoke(null, arguments); + // Skip blank lines, to avoid unnecessary trailing empties. + actualLines = builder.ToString().Replace("\r\n", "\n").Split('\n').Where(line => line != "").ToList(); + } + finally + { + Console.SetOut(oldOut); + } + var expectedLines = Metadata.ExpectedOutput ?? new List(); + return ValidateExpectedAgainstActual("output", expectedLines, actualLines); + } + + bool ValidateExpectedAgainstActual(string type, List expected, List actual) + { + if (!expected.SequenceEqual(actual)) + { + Console.WriteLine($" Mismatched {type}: Expected {string.Join(", ", expected)}; Was {string.Join(", ", actual)}"); + return false; + } + return true; + } + } +} diff --git a/tools/ExampleTester/Program.cs b/tools/ExampleTester/Program.cs new file mode 100644 index 000000000..8e6f66306 --- /dev/null +++ b/tools/ExampleTester/Program.cs @@ -0,0 +1,35 @@ +using ExampleTester; + +if (args.Length != 1) +{ + Console.WriteLine("Arguments: "); + Console.WriteLine("(This directory is the one containing a subdirectory per example.)"); + return 1; +} + +var parentDirectory = args[0]; + +if (!Directory.Exists(parentDirectory)) +{ + Console.WriteLine($"Error: '{parentDirectory}' does not exist or is not a directory"); + return 1; +} + +int failures = 0; +var allExamples = GeneratedExample.LoadAllExamples(parentDirectory) + .OrderBy(e => e.Metadata.MarkdownFile).ThenBy(e => e.Metadata.StartLine) + .ToList(); +foreach (var example in allExamples) +{ + // The Run method explains any failures, we just need to count them. + if (!await example.Test()) + { + failures++; + } +} + +Console.WriteLine(); +Console.WriteLine($"Tests: {allExamples.Count}"); +Console.WriteLine($"Failures: {failures}"); + +return failures; diff --git a/tools/example-templates/standalone-console/Program.cs b/tools/example-templates/standalone-console/Program.cs new file mode 100644 index 000000000..bb8d3f010 --- /dev/null +++ b/tools/example-templates/standalone-console/Program.cs @@ -0,0 +1 @@ +$example-code diff --git a/tools/example-templates/standalone-console/Project.csproj b/tools/example-templates/standalone-console/Project.csproj new file mode 100644 index 000000000..2a1c70ea3 --- /dev/null +++ b/tools/example-templates/standalone-console/Project.csproj @@ -0,0 +1,11 @@ + + + + Exe + net6.0 + enable + disable + $example-name + + + diff --git a/tools/example-templates/standalone-lib/Library.cs b/tools/example-templates/standalone-lib/Library.cs new file mode 100644 index 000000000..bb8d3f010 --- /dev/null +++ b/tools/example-templates/standalone-lib/Library.cs @@ -0,0 +1 @@ +$example-code diff --git a/tools/example-templates/standalone-lib/Project.csproj b/tools/example-templates/standalone-lib/Project.csproj new file mode 100644 index 000000000..46dd51ea4 --- /dev/null +++ b/tools/example-templates/standalone-lib/Project.csproj @@ -0,0 +1,9 @@ + + + + net6.0 + enable + $example-name + + + diff --git a/tools/test-examples.sh b/tools/test-examples.sh new file mode 100755 index 000000000..cce549696 --- /dev/null +++ b/tools/test-examples.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +set -e + +dotnet run --project ExampleExtractor -- ../standard example-templates tmp + +echo "" + +dotnet run --project ExampleTester -- tmp diff --git a/tools/tools.sln b/tools/tools.sln index ca4fa4bb8..e593e98a7 100644 --- a/tools/tools.sln +++ b/tools/tools.sln @@ -11,7 +11,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GetGrammar", "GetGrammar\Ge EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Utilities", "Utilities\Utilities.csproj", "{835C6333-BDB5-4DEC-B3BE-4300E3F948AF}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MarkdownConverter.Tests", "MarkdownConverter.Tests\MarkdownConverter.Tests.csproj", "{E6B72453-1C88-4153-B82E-EB0A12C96345}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MarkdownConverter.Tests", "MarkdownConverter.Tests\MarkdownConverter.Tests.csproj", "{E6B72453-1C88-4153-B82E-EB0A12C96345}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExampleExtractor", "ExampleExtractor\ExampleExtractor.csproj", "{571E69B9-07A3-4682-B692-876B016315CD}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExampleTester", "ExampleTester\ExampleTester.csproj", "{829FE7D6-B7E7-48DF-923A-73A79921E997}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -83,6 +87,30 @@ Global {E6B72453-1C88-4153-B82E-EB0A12C96345}.Release|x64.Build.0 = Release|Any CPU {E6B72453-1C88-4153-B82E-EB0A12C96345}.Release|x86.ActiveCfg = Release|Any CPU {E6B72453-1C88-4153-B82E-EB0A12C96345}.Release|x86.Build.0 = Release|Any CPU + {571E69B9-07A3-4682-B692-876B016315CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {571E69B9-07A3-4682-B692-876B016315CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {571E69B9-07A3-4682-B692-876B016315CD}.Debug|x64.ActiveCfg = Debug|Any CPU + {571E69B9-07A3-4682-B692-876B016315CD}.Debug|x64.Build.0 = Debug|Any CPU + {571E69B9-07A3-4682-B692-876B016315CD}.Debug|x86.ActiveCfg = Debug|Any CPU + {571E69B9-07A3-4682-B692-876B016315CD}.Debug|x86.Build.0 = Debug|Any CPU + {571E69B9-07A3-4682-B692-876B016315CD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {571E69B9-07A3-4682-B692-876B016315CD}.Release|Any CPU.Build.0 = Release|Any CPU + {571E69B9-07A3-4682-B692-876B016315CD}.Release|x64.ActiveCfg = Release|Any CPU + {571E69B9-07A3-4682-B692-876B016315CD}.Release|x64.Build.0 = Release|Any CPU + {571E69B9-07A3-4682-B692-876B016315CD}.Release|x86.ActiveCfg = Release|Any CPU + {571E69B9-07A3-4682-B692-876B016315CD}.Release|x86.Build.0 = Release|Any CPU + {829FE7D6-B7E7-48DF-923A-73A79921E997}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {829FE7D6-B7E7-48DF-923A-73A79921E997}.Debug|Any CPU.Build.0 = Debug|Any CPU + {829FE7D6-B7E7-48DF-923A-73A79921E997}.Debug|x64.ActiveCfg = Debug|Any CPU + {829FE7D6-B7E7-48DF-923A-73A79921E997}.Debug|x64.Build.0 = Debug|Any CPU + {829FE7D6-B7E7-48DF-923A-73A79921E997}.Debug|x86.ActiveCfg = Debug|Any CPU + {829FE7D6-B7E7-48DF-923A-73A79921E997}.Debug|x86.Build.0 = Debug|Any CPU + {829FE7D6-B7E7-48DF-923A-73A79921E997}.Release|Any CPU.ActiveCfg = Release|Any CPU + {829FE7D6-B7E7-48DF-923A-73A79921E997}.Release|Any CPU.Build.0 = Release|Any CPU + {829FE7D6-B7E7-48DF-923A-73A79921E997}.Release|x64.ActiveCfg = Release|Any CPU + {829FE7D6-B7E7-48DF-923A-73A79921E997}.Release|x64.Build.0 = Release|Any CPU + {829FE7D6-B7E7-48DF-923A-73A79921E997}.Release|x86.ActiveCfg = Release|Any CPU + {829FE7D6-B7E7-48DF-923A-73A79921E997}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE