Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SnippetGenerator #7928

Merged
merged 8 commits into from
Oct 4, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions eng/SnippetGenerator/MarkdownProcessor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Text.RegularExpressions;

namespace SnippetGenerator
{
public class MarkdownProcessor
heaths marked this conversation as resolved.
Show resolved Hide resolved
{
private static readonly string _snippetFormat = "```C# {0}{1}{2}```";
private static Regex _snippetRegex = new Regex("```\\s*?C#[ ]*?(?<name>[\\w:]+).*?```",
RegexOptions.Compiled | RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.IgnoreCase);

public static string Process(string markdown, Func<string, string> snippetProvider)
{
return _snippetRegex.Replace(markdown, match =>
{
var matchGroup = match.Groups["name"];
if (matchGroup.Success)
pakrym marked this conversation as resolved.
Show resolved Hide resolved
{
return string.Format(_snippetFormat, matchGroup.Value, Environment.NewLine, snippetProvider(matchGroup.Value));
}

return match.Value;
});
}
}
}
166 changes: 166 additions & 0 deletions eng/SnippetGenerator/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using McMaster.Extensions.CommandLineUtils;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;

namespace SnippetGenerator
{
public class Program
{
[Option(ShortName = "u")]
public string Markdown { get; set; }

[Option(ShortName = "s")]
public string Snippets { get; set; }

public static int Main(string[] args)
{
return CommandLineApplication.Execute<Program>(args);
}

public async Task OnExecuteAsync()
{
Console.WriteLine($"Processing {Markdown}");

var text = File.ReadAllText(Markdown);
var snippets = await GetSnippetsInDirectory(Snippets);
Console.WriteLine($"Discovered snippets:");
pakrym marked this conversation as resolved.
Show resolved Hide resolved

foreach (var snippet in snippets)
{
Console.WriteLine($" {snippet.Name}");
}

text = MarkdownProcessor.Process(text, s => {
var selectedSnippets = snippets.Where(snip => snip.Name == s).ToArray();
if (selectedSnippets.Length > 1)
{
throw new InvalidOperationException($"Multiple snippets with the name '{s}' defined '{Snippets}'");
}
if (selectedSnippets.Length == 0)
{
throw new InvalidOperationException($"Snippet '{s}' not found in directory '{Snippets}'");
}

var selectedSnippet = selectedSnippets.Single();
Console.WriteLine($"Replaced {selectedSnippet.Name}");
return FormatSnippet(selectedSnippet.Text);
});

File.WriteAllText(Markdown, text);
}

private string FormatSnippet(SourceText text)
{
int minIndent = int.MaxValue;
int firstLine = 0;
var lines = text.Lines.Select(l => l.ToString()).ToArray();

int lastLine = lines.Length - 1;

while (string.IsNullOrWhiteSpace(lines[firstLine]))
{
firstLine++;
}

while (string.IsNullOrWhiteSpace(lines[lastLine]))
{
lastLine--;
}

for (var index = firstLine; index <= lastLine; index++)
{
var textLine = lines[index];

if (string.IsNullOrWhiteSpace(textLine))
{
continue;
}

int i;
for (i = 0; i < textLine.Length; i++)
{
if (!char.IsWhiteSpace(textLine[i])) break;
}

minIndent = Math.Min(minIndent, i);
}

var stringBuilder = new StringBuilder();
for (var index = firstLine; index <= lastLine; index++)
{
var line = lines[index];
line = string.IsNullOrWhiteSpace(line) ? string.Empty : line.Substring(minIndent);
stringBuilder.AppendLine(line);
}

return stringBuilder.ToString();
}

private async Task<List<Snippet>> GetSnippetsInDirectory(string baseDirectory)
{
var list = new List<Snippet>();
foreach (var file in Directory.GetFiles(baseDirectory, "*.cs", SearchOption.AllDirectories))
{
var syntaxTree = CSharpSyntaxTree.ParseText(
File.ReadAllText(file),
new CSharpParseOptions(LanguageVersion.Preview),
path: file);
list.AddRange(await GetAllSnippetsAsync(syntaxTree));
}

return list;
}

private async Task<Snippet[]> GetAllSnippetsAsync(SyntaxTree syntaxTree)
{
var snippets = new List<Snippet>();
var directiveWalker = new DirectiveWalker();
directiveWalker.Visit(await syntaxTree.GetRootAsync());

foreach (var region in directiveWalker.Regions)
{
var syntaxTrivia = region.Item1.EndOfDirectiveToken.LeadingTrivia.First(t => t.IsKind(SyntaxKind.PreprocessingMessageTrivia));
var fromBounds = TextSpan.FromBounds(
region.Item1.GetLocation().SourceSpan.End,
region.Item2.GetLocation().SourceSpan.Start);

snippets.Add(new Snippet(syntaxTrivia.ToString(), syntaxTree.GetText().GetSubText(fromBounds)));
}

return snippets.ToArray();
}

class DirectiveWalker : CSharpSyntaxWalker
{
private Stack<RegionDirectiveTriviaSyntax> _regions = new Stack<RegionDirectiveTriviaSyntax>();
public List<(RegionDirectiveTriviaSyntax, EndRegionDirectiveTriviaSyntax)> Regions { get; } = new List<(RegionDirectiveTriviaSyntax, EndRegionDirectiveTriviaSyntax)>();

public DirectiveWalker() : base(SyntaxWalkerDepth.StructuredTrivia)
{
}

public override void VisitRegionDirectiveTrivia(RegionDirectiveTriviaSyntax node)
{
base.VisitRegionDirectiveTrivia(node);
_regions.Push(node);
}

public override void VisitEndRegionDirectiveTrivia(EndRegionDirectiveTriviaSyntax node)
{
base.VisitEndRegionDirectiveTrivia(node);
Regions.Add((_regions.Pop(), node));
}
}
}
}
19 changes: 19 additions & 0 deletions eng/SnippetGenerator/Snippet.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Microsoft.CodeAnalysis.Text;

namespace SnippetGenerator
{
internal class Snippet
{
public string Name { get; }
public SourceText Text { get; }

public Snippet(string name, SourceText text)
{
Name = name;
Text = text;
}
}
}
17 changes: 17 additions & 0 deletions eng/SnippetGenerator/SnippetGenerator.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp2.1</TargetFramework>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="McMaster.Extensions.CommandLineUtils" Version="2.3.4" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.2.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.2.1" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="3.2.1" />
<PackageReference Include="Microsoft.Build" Version="16.0.461" />
<PackageReference Include="Microsoft.Build.Tasks.Core" Version="16.0.461" />
</ItemGroup>

</Project>
25 changes: 25 additions & 0 deletions eng/SnippetGenerator/SnippetGenerator.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29315.20
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SnippetGenerator", "SnippetGenerator.csproj", "{DC46BB54-17A2-471C-A21A-D7F329505955}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{DC46BB54-17A2-471C-A21A-D7F329505955}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DC46BB54-17A2-471C-A21A-D7F329505955}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DC46BB54-17A2-471C-A21A-D7F329505955}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DC46BB54-17A2-471C-A21A-D7F329505955}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8162DAAE-F789-44E8-8552-58A3581A0AF3}
EndGlobalSection
EndGlobal
11 changes: 11 additions & 0 deletions eng/Update-Snippets.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
$generatorProject = "$PSScriptRoot\SnippetGenerator\SnippetGenerator.csproj";
dotnet build $generatorProject

foreach ($file in Get-ChildItem "$PSScriptRoot\..\sdk" -Filter README.md -Recurse)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we considered potentially keeping snippets in their own files, so that each can be a small and contained test. I'm not a fan of regions, personally, and worry about polluting the sample code with them will make things less clear to the casual reader.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you want to inject the entire file with headers and everything?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking more of keeping the region-based approach, but segmenting it from the samples proper. In some form that can be run and validated as part of the nightly process, but maybe a set of tests in its own folder or something similar.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to improvements but wanted to start with something simple

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure that mine is the best suggestion either. One of the goals that we had discussed was helping developers understand the copy/paste worthy areas of the samples, which the regions do - if (and only if) you understand the convention.

Too bad that we can't create our own alias for region and use something like:

var code = ...

#snippet Do the thing

// Stuff

#endsnippet

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know that @danieljurek is planning to start digging into samples and help define a convention so that they can essentially be standalone examples and can be ran during our smoke testing. Personally also avoid using regions but instead just use the entire method body. We can easily attribute the method with the name for the sample so that we can align them with the readmes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also by attribute we don't actually have to depend on a .NET Attribute type but we can simply add a structured comment on the method and look for that as well.

Copy link
Member

@jsquire jsquire Oct 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think using the full method would definitely favor not using the full samples, lest our ReadMe files become a novel. It could work in something isolated with a very intentional scoping to snippet use.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DocFX uses regions that's why I went with them, it would be easy to reuse code when writing doc articles.

{
$samples = Join-Path $file.Directory "samples"
if (Test-Path $samples)
{
dotnet run -p $generatorProject --no-build -u $file.FullName -s $samples
}
}
Loading