Add file scoped extensible directives.

- Added `DirectiveUsage` to enable extensible directive authors to indicate how their directives should be used. Currently support `Unrestricted` (how section directives have always worked) and a file scoped singly occurring directive.
- Added directive parsing tests.
- Removed no longer used `BlockKindInternal` items.

#1376
This commit is contained in:
N. Taylor Mullen 2017-06-12 17:36:41 -07:00
parent e3b3e20738
commit 2453689804
10 changed files with 415 additions and 9 deletions

View File

@ -38,6 +38,11 @@ namespace Microsoft.AspNetCore.Razor.Language
/// </summary>
public abstract DirectiveKind Kind { get; }
/// <summary>
/// Gets the way a directive can be used. The usage determines how many, and where directives can exist per document.
/// </summary>
public abstract DirectiveUsage Usage { get; }
/// <summary>
/// Gets the list of directive tokens that can follow the directive keyword.
/// </summary>
@ -189,6 +194,8 @@ namespace Microsoft.AspNetCore.Razor.Language
public DirectiveKind Kind { get; }
public DirectiveUsage Usage { get; set; }
public IList<DirectiveTokenDescriptor> Tokens { get; }
public DirectiveDescriptor Build()
@ -218,7 +225,7 @@ namespace Microsoft.AspNetCore.Razor.Language
}
}
return new DefaultDirectiveDescriptor(Directive, Kind, Tokens.ToArray(), DisplayName, Description);
return new DefaultDirectiveDescriptor(Directive, Kind, Usage, Tokens.ToArray(), DisplayName, Description);
}
}
@ -227,12 +234,14 @@ namespace Microsoft.AspNetCore.Razor.Language
public DefaultDirectiveDescriptor(
string directive,
DirectiveKind kind,
DirectiveUsage usage,
DirectiveTokenDescriptor[] tokens,
string displayName,
string description)
{
Directive = directive;
Kind = kind;
Usage = usage;
Tokens = tokens;
DisplayName = displayName;
Description = description;
@ -246,6 +255,8 @@ namespace Microsoft.AspNetCore.Razor.Language
public override DirectiveKind Kind { get; }
public override DirectiveUsage Usage { get; }
public override IReadOnlyList<DirectiveTokenDescriptor> Tokens { get; }
}
}

View File

@ -0,0 +1,11 @@
// Copyright(c) .NET Foundation.All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
namespace Microsoft.AspNetCore.Razor.Language
{
public enum DirectiveUsage
{
Unrestricted,
FileScopedSinglyOccurring,
}
}

View File

@ -30,6 +30,11 @@ namespace Microsoft.AspNetCore.Razor.Language
/// </summary>
DirectiveKind Kind { get; }
/// <summary>
/// Gets or sets the directive usage. The usage determines how many, and where directives can exist per document.
/// </summary>
DirectiveUsage Usage { get; set; }
/// <summary>
/// Gets a list of the directive tokens.
/// </summary>

View File

@ -8,13 +8,10 @@ namespace Microsoft.AspNetCore.Razor.Language.Legacy
// Code
Statement,
Directive,
Functions,
Expression,
Helper,
// Markup
Markup,
Section,
Template,
// Special

View File

@ -54,6 +54,7 @@ namespace Microsoft.AspNetCore.Razor.Language.Legacy
private Dictionary<string, Action> _directiveParsers = new Dictionary<string, Action>(StringComparer.Ordinal);
private Dictionary<CSharpKeyword, Action<bool>> _keywordParsers = new Dictionary<CSharpKeyword, Action<bool>>();
private HashSet<string> _seenDirectives = new HashSet<string>(StringComparer.Ordinal);
public CSharpCodeParser(ParserContext context)
: this(directives: Enumerable.Empty<DirectiveDescriptor>(), context: context)
@ -91,7 +92,12 @@ namespace Microsoft.AspNetCore.Razor.Language.Legacy
{
foreach (var directive in directives)
{
_directiveParsers.Add(directive, handler);
_directiveParsers.Add(directive, () =>
{
handler();
_seenDirectives.Add(directive);
});
Keywords.Add(directive);
// These C# keywords are reserved for use in directives. It's an error to use them outside of
@ -1589,10 +1595,13 @@ namespace Microsoft.AspNetCore.Razor.Language.Legacy
AcceptAndMoveNext();
Output(SpanKindInternal.MetaCode, AcceptedCharactersInternal.None);
// Even if an error was logged do not bail out early. If a directive was used incorrectly it doesn't mean it can't be parsed.
ValidateDirectiveUsage(descriptor);
for (var i = 0; i < descriptor.Tokens.Count; i++)
{
if (!At(CSharpSymbolType.WhiteSpace) &&
!At(CSharpSymbolType.NewLine) &&
!At(CSharpSymbolType.NewLine) &&
!EndOfFile)
{
Context.ErrorSink.OnError(
@ -1767,6 +1776,56 @@ namespace Microsoft.AspNetCore.Razor.Language.Legacy
}
}
private void ValidateDirectiveUsage(DirectiveDescriptor descriptor)
{
if (descriptor.Usage == DirectiveUsage.FileScopedSinglyOccurring)
{
if (_seenDirectives.Contains(descriptor.Directive))
{
UsageError(Resources.FormatDuplicateDirective(descriptor.Directive));
return;
}
var root = Context.Builder.ActiveBlocks.Last();
for (var i = 0; i < root.Children.Count; i++)
{
// Directives, comments and whitespace are valid prior to an unnested directive.
var child = root.Children[i];
if (child is Legacy.Block block)
{
if (block.Type == BlockKindInternal.Directive || block.Type == BlockKindInternal.Comment)
{
continue;
}
}
else if (child is Span span)
{
if (span.Length == 0 ||
span.Kind == SpanKindInternal.Comment ||
span.Symbols.All(symbol => string.IsNullOrWhiteSpace(symbol.Content)))
{
continue;
}
}
UsageError(Resources.FormatDirectiveMustExistBeforeMarkupOrCode(descriptor.Directive));
return;
}
}
return;
void UsageError(string message)
{
// There wil always be at least 1 child because of the `@` transition.
var directiveStart = Context.Builder.CurrentBlock.Children.First().Start;
var errorLength = /* @ */ 1 + descriptor.Directive.Length;
Context.ErrorSink.OnError(directiveStart, message, errorLength);
}
}
private void ParseDirectiveBlock(DirectiveDescriptor descriptor, Action<SourceLocation> parseChildren)
{
if (EndOfFile)

View File

@ -17,7 +17,7 @@ namespace Microsoft.AspNetCore.Razor.Language.Legacy
_endBlock = EndBlock;
}
public IEnumerable<BlockBuilder> ActiveBlocks => _blockStack;
public IReadOnlyCollection<BlockBuilder> ActiveBlocks => _blockStack;
public BlockBuilder CurrentBlock => _blockStack.Peek();

View File

@ -514,6 +514,34 @@ namespace Microsoft.AspNetCore.Razor.Language
internal static string FormatIntermediateNodeReference_CollectionIsReadOnly(object p0)
=> string.Format(CultureInfo.CurrentCulture, GetString("IntermediateNodeReference_CollectionIsReadOnly"), p0);
/// <summary>
/// The '{0}' directive may only occur once per document.
/// </summary>
internal static string DuplicateDirective
{
get => GetString("DuplicateDirective");
}
/// <summary>
/// The '{0}' directive may only occur once per document.
/// </summary>
internal static string FormatDuplicateDirective(object p0)
=> string.Format(CultureInfo.CurrentCulture, GetString("DuplicateDirective"), p0);
/// <summary>
/// The '{0}' directive must exist prior to markup or code.
/// </summary>
internal static string DirectiveMustExistBeforeMarkupOrCode
{
get => GetString("DirectiveMustExistBeforeMarkupOrCode");
}
/// <summary>
/// The '{0}' directive must exist prior to markup or code.
/// </summary>
internal static string FormatDirectiveMustExistBeforeMarkupOrCode(object p0)
=> string.Format(CultureInfo.CurrentCulture, GetString("DirectiveMustExistBeforeMarkupOrCode"), p0);
private static string GetString(string name, params string[] formatterNames)
{
var value = _resourceManager.GetString(name);

View File

@ -225,4 +225,10 @@
<data name="IntermediateNodeReference_CollectionIsReadOnly" xml:space="preserve">
<value>The node '{0}' has a read-only child collection and cannot be modified.</value>
</data>
<data name="DuplicateDirective" xml:space="preserve">
<value>The '{0}' directive may only occur once per document.</value>
</data>
<data name="DirectiveMustExistBeforeMarkupOrCode" xml:space="preserve">
<value>The '{0}' directive must exist prior to markup or code.</value>
</data>
</root>

View File

@ -30,7 +30,7 @@ namespace Microsoft.AspNetCore.Razor.Language.Legacy
var expected = new ExpressionChunkGenerator();
var builder = new BlockBuilder()
{
Type = BlockKindInternal.Helper,
Type = BlockKindInternal.Statement,
ChunkGenerator = expected
};
@ -48,7 +48,7 @@ namespace Microsoft.AspNetCore.Razor.Language.Legacy
var expected = new SpanBuilder(SourceLocation.Undefined) { Kind = SpanKindInternal.Code }.Build();
var builder = new BlockBuilder()
{
Type = BlockKindInternal.Functions
Type = BlockKindInternal.Statement
};
builder.Children.Add(expected);

View File

@ -11,6 +11,295 @@ namespace Microsoft.AspNetCore.Razor.Language.Legacy
{
public class CSharpDirectivesTest : CsHtmlCodeParserTestBase
{
[Fact]
public void DirectiveDescriptor_SinglePreContent_ErrorsIfNestedInCode()
{
// Arrange
var descriptor = DirectiveDescriptor.CreateDirective(
"custom",
DirectiveKind.SingleLine,
builder =>
{
builder.Usage = DirectiveUsage.SinglePreContent;
builder.AddTypeToken();
});
var chunkGenerator = new DirectiveChunkGenerator(descriptor);
chunkGenerator.Diagnostics.Add(
RazorDiagnostic.Create(
new RazorError(
Resources.FormatDirectiveMustExistBeforeMarkupOrCode("custom"),
1 + Environment.NewLine.Length, 1, 0, 7)));
// Act & Assert
ParseCodeBlockTest(
@"{
@custom System.Text.Encoding.ASCIIEncoding
}",
new[] { descriptor },
new StatementBlock(
Factory.MetaCode("{").Accepts(AcceptedCharactersInternal.None),
Factory.Code(Environment.NewLine)
.AsStatement()
.AutoCompleteWith(autoCompleteString: null, atEndOfSpan: false),
new DirectiveBlock(chunkGenerator,
Factory.CodeTransition(),
Factory.MetaCode("custom").Accepts(AcceptedCharactersInternal.None),
Factory.Span(SpanKindInternal.Code, " ", markup: false).Accepts(AcceptedCharactersInternal.WhiteSpace),
Factory.Span(SpanKindInternal.Code, "System.Text.Encoding.ASCIIEncoding", markup: false).AsDirectiveToken(descriptor.Tokens[0]),
Factory.MetaCode(Environment.NewLine).Accepts(AcceptedCharactersInternal.WhiteSpace)),
Factory.EmptyCSharp().AsStatement(),
Factory.MetaCode("}").Accepts(AcceptedCharactersInternal.None)));
}
[Fact]
public void DirectiveDescriptor_SinglePreContent_ErrorsIfNestedInHtml()
{
// Arrange
var descriptor = DirectiveDescriptor.CreateDirective(
"custom",
DirectiveKind.SingleLine,
builder =>
{
builder.Usage = DirectiveUsage.SinglePreContent;
builder.AddTypeToken();
});
var chunkGenerator = new DirectiveChunkGenerator(descriptor);
chunkGenerator.Diagnostics.Add(
RazorDiagnostic.Create(
new RazorError(
Resources.FormatDirectiveMustExistBeforeMarkupOrCode("custom"),
3 + Environment.NewLine.Length, 1, 0, 7)));
// Act & Assert
ParseDocumentTest(
@"<p>
@custom System.Text.Encoding.ASCIIEncoding
</p>",
new[] { descriptor },
new MarkupBlock(
BlockFactory.MarkupTagBlock("<p>"),
Factory.Markup(Environment.NewLine),
new DirectiveBlock(chunkGenerator,
Factory.CodeTransition(),
Factory.MetaCode("custom").Accepts(AcceptedCharactersInternal.None),
Factory.Span(SpanKindInternal.Code, " ", markup: false).Accepts(AcceptedCharactersInternal.WhiteSpace),
Factory.Span(SpanKindInternal.Code, "System.Text.Encoding.ASCIIEncoding", markup: false).AsDirectiveToken(descriptor.Tokens[0]),
Factory.MetaCode(Environment.NewLine).Accepts(AcceptedCharactersInternal.WhiteSpace)),
BlockFactory.MarkupTagBlock("</p>")));
}
[Fact]
public void DirectiveDescriptor_SinglePreContent_ErrorsIfDuplicate()
{
// Arrange
var descriptor = DirectiveDescriptor.CreateDirective(
"custom",
DirectiveKind.SingleLine,
builder =>
{
builder.Usage = DirectiveUsage.SinglePreContent;
builder.AddTypeToken();
});
var chunkGenerator = new DirectiveChunkGenerator(descriptor);
chunkGenerator.Diagnostics.Add(
RazorDiagnostic.Create(
new RazorError(
Resources.FormatDuplicateDirective("custom"),
42 + Environment.NewLine.Length, 1, 0, 7)));
// Act & Assert
ParseDocumentTest(
@"@custom System.Text.Encoding.ASCIIEncoding
@custom System.Text.Encoding.UTF8Encoding",
new[] { descriptor },
new MarkupBlock(
Factory.EmptyHtml(),
new DirectiveBlock(new DirectiveChunkGenerator(descriptor),
Factory.CodeTransition(),
Factory.MetaCode("custom").Accepts(AcceptedCharactersInternal.None),
Factory.Span(SpanKindInternal.Code, " ", markup: false).Accepts(AcceptedCharactersInternal.WhiteSpace),
Factory.Span(SpanKindInternal.Code, "System.Text.Encoding.ASCIIEncoding", markup: false).AsDirectiveToken(descriptor.Tokens[0]),
Factory.MetaCode(Environment.NewLine).Accepts(AcceptedCharactersInternal.WhiteSpace)),
Factory.EmptyHtml(),
new DirectiveBlock(chunkGenerator,
Factory.CodeTransition(),
Factory.MetaCode("custom").Accepts(AcceptedCharactersInternal.None),
Factory.Span(SpanKindInternal.Code, " ", markup: false).Accepts(AcceptedCharactersInternal.WhiteSpace),
Factory.Span(SpanKindInternal.Code, "System.Text.Encoding.UTF8Encoding", markup: false).AsDirectiveToken(descriptor.Tokens[0])),
Factory.EmptyHtml()));
}
[Fact]
public void DirectiveDescriptor_SinglePreContent_CanBeBeneathOtherDirectives()
{
// Arrange
var customDescriptor = DirectiveDescriptor.CreateDirective(
"custom",
DirectiveKind.SingleLine,
builder =>
{
builder.Usage = DirectiveUsage.SinglePreContent;
builder.AddTypeToken();
});
var somethingDescriptor = DirectiveDescriptor.CreateDirective(
"something",
DirectiveKind.SingleLine,
builder =>
{
builder.Usage = DirectiveUsage.SinglePreContent;
builder.AddMemberToken();
});
// Act & Assert
ParseDocumentTest(
@"@custom System.Text.Encoding.ASCIIEncoding
@something Else",
new[] { customDescriptor, somethingDescriptor },
new MarkupBlock(
Factory.EmptyHtml(),
new DirectiveBlock(new DirectiveChunkGenerator(customDescriptor),
Factory.CodeTransition(),
Factory.MetaCode("custom").Accepts(AcceptedCharactersInternal.None),
Factory.Span(SpanKindInternal.Code, " ", markup: false).Accepts(AcceptedCharactersInternal.WhiteSpace),
Factory.Span(SpanKindInternal.Code, "System.Text.Encoding.ASCIIEncoding", markup: false).AsDirectiveToken(customDescriptor.Tokens[0]),
Factory.MetaCode(Environment.NewLine).Accepts(AcceptedCharactersInternal.WhiteSpace)),
Factory.EmptyHtml(),
new DirectiveBlock(new DirectiveChunkGenerator(somethingDescriptor),
Factory.CodeTransition(),
Factory.MetaCode("something").Accepts(AcceptedCharactersInternal.None),
Factory.Span(SpanKindInternal.Code, " ", markup: false).Accepts(AcceptedCharactersInternal.WhiteSpace),
Factory.Span(SpanKindInternal.Code, "Else", markup: false).AsDirectiveToken(somethingDescriptor.Tokens[0])),
Factory.EmptyHtml()));
}
[Fact]
public void DirectiveDescriptor_SinglePreContent_CanBeBeneathOtherWhiteSpaceCommentsAndDirectives()
{
// Arrange
var customDescriptor = DirectiveDescriptor.CreateDirective(
"custom",
DirectiveKind.SingleLine,
builder =>
{
builder.Usage = DirectiveUsage.SinglePreContent;
builder.AddTypeToken();
});
var somethingDescriptor = DirectiveDescriptor.CreateDirective(
"something",
DirectiveKind.SingleLine,
builder =>
{
builder.Usage = DirectiveUsage.SinglePreContent;
builder.AddMemberToken();
});
// Act & Assert
ParseDocumentTest(
@"@* There are two directives beneath this *@
@custom System.Text.Encoding.ASCIIEncoding
@something Else
<p>This is extra</p>",
new[] { customDescriptor, somethingDescriptor },
new MarkupBlock(
Factory.EmptyHtml(),
new CommentBlock(
Factory.MarkupTransition(HtmlSymbolType.RazorCommentTransition).Accepts(AcceptedCharactersInternal.None),
Factory.MetaMarkup("*", HtmlSymbolType.RazorCommentStar).Accepts(AcceptedCharactersInternal.None),
Factory.Span(SpanKindInternal.Comment, new HtmlSymbol(" There are two directives beneath this ", HtmlSymbolType.RazorComment)).Accepts(AcceptedCharactersInternal.Any),
Factory.MetaMarkup("*", HtmlSymbolType.RazorCommentStar).Accepts(AcceptedCharactersInternal.None),
Factory.MarkupTransition(HtmlSymbolType.RazorCommentTransition).Accepts(AcceptedCharactersInternal.None)),
Factory.Markup(Environment.NewLine),
new DirectiveBlock(new DirectiveChunkGenerator(customDescriptor),
Factory.CodeTransition(),
Factory.MetaCode("custom").Accepts(AcceptedCharactersInternal.None),
Factory.Span(SpanKindInternal.Code, " ", markup: false).Accepts(AcceptedCharactersInternal.WhiteSpace),
Factory.Span(SpanKindInternal.Code, "System.Text.Encoding.ASCIIEncoding", markup: false).AsDirectiveToken(customDescriptor.Tokens[0]),
Factory.MetaCode(Environment.NewLine).Accepts(AcceptedCharactersInternal.WhiteSpace)),
Factory.Markup(Environment.NewLine),
new DirectiveBlock(new DirectiveChunkGenerator(somethingDescriptor),
Factory.CodeTransition(),
Factory.MetaCode("something").Accepts(AcceptedCharactersInternal.None),
Factory.Span(SpanKindInternal.Code, " ", markup: false).Accepts(AcceptedCharactersInternal.WhiteSpace),
Factory.Span(SpanKindInternal.Code, "Else", markup: false).AsDirectiveToken(somethingDescriptor.Tokens[0]),
Factory.MetaCode(Environment.NewLine).Accepts(AcceptedCharactersInternal.WhiteSpace)),
Factory.Markup(Environment.NewLine),
BlockFactory.MarkupTagBlock("<p>"),
Factory.Markup("This is extra"),
BlockFactory.MarkupTagBlock("</p>")));
}
[Fact]
public void DirectiveDescriptor_SinglePreContent_MixedContentErrors()
{
// Arrange
var customDescriptor = DirectiveDescriptor.CreateDirective(
"custom",
DirectiveKind.SingleLine,
builder =>
{
builder.Usage = DirectiveUsage.SinglePreContent;
builder.AddTypeToken();
});
var somethingDescriptor = DirectiveDescriptor.CreateDirective(
"something",
DirectiveKind.SingleLine,
builder =>
{
builder.Usage = DirectiveUsage.SinglePreContent;
builder.AddMemberToken();
});
var chunkGenerator = new DirectiveChunkGenerator(somethingDescriptor);
chunkGenerator.Diagnostics.Add(
RazorDiagnostic.Create(
new RazorError(
Resources.FormatDirectiveMustExistBeforeMarkupOrCode("something"),
151 + Environment.NewLine.Length * 4, 4, 0, 10)));
// Act & Assert
ParseDocumentTest(
@"@custom System.Text.Encoding.ASCIIEncoding
@* There is invalid content beneath this *@
<p>Should cause error</p>
@* There is invalid content above this *@
@something Else",
new[] { customDescriptor, somethingDescriptor },
new MarkupBlock(
Factory.EmptyHtml(),
new DirectiveBlock(new DirectiveChunkGenerator(customDescriptor),
Factory.CodeTransition(),
Factory.MetaCode("custom").Accepts(AcceptedCharactersInternal.None),
Factory.Span(SpanKindInternal.Code, " ", markup: false).Accepts(AcceptedCharactersInternal.WhiteSpace),
Factory.Span(SpanKindInternal.Code, "System.Text.Encoding.ASCIIEncoding", markup: false).AsDirectiveToken(customDescriptor.Tokens[0]),
Factory.MetaCode(Environment.NewLine).Accepts(AcceptedCharactersInternal.WhiteSpace)),
Factory.EmptyHtml(),
new CommentBlock(
Factory.MarkupTransition(HtmlSymbolType.RazorCommentTransition).Accepts(AcceptedCharactersInternal.None),
Factory.MetaMarkup("*", HtmlSymbolType.RazorCommentStar).Accepts(AcceptedCharactersInternal.None),
Factory.Span(SpanKindInternal.Comment, new HtmlSymbol(" There is invalid content beneath this ", HtmlSymbolType.RazorComment)).Accepts(AcceptedCharactersInternal.Any),
Factory.MetaMarkup("*", HtmlSymbolType.RazorCommentStar).Accepts(AcceptedCharactersInternal.None),
Factory.MarkupTransition(HtmlSymbolType.RazorCommentTransition).Accepts(AcceptedCharactersInternal.None)),
Factory.Markup(Environment.NewLine),
BlockFactory.MarkupTagBlock("<p>"),
Factory.Markup("Should cause error"),
BlockFactory.MarkupTagBlock("</p>"),
Factory.Markup(Environment.NewLine),
new CommentBlock(
Factory.MarkupTransition(HtmlSymbolType.RazorCommentTransition).Accepts(AcceptedCharactersInternal.None),
Factory.MetaMarkup("*", HtmlSymbolType.RazorCommentStar).Accepts(AcceptedCharactersInternal.None),
Factory.Span(SpanKindInternal.Comment, new HtmlSymbol(" There is invalid content above this ", HtmlSymbolType.RazorComment)).Accepts(AcceptedCharactersInternal.Any),
Factory.MetaMarkup("*", HtmlSymbolType.RazorCommentStar).Accepts(AcceptedCharactersInternal.None),
Factory.MarkupTransition(HtmlSymbolType.RazorCommentTransition).Accepts(AcceptedCharactersInternal.None)),
Factory.Markup(Environment.NewLine).With(SpanChunkGenerator.Null),
new DirectiveBlock(chunkGenerator,
Factory.CodeTransition(),
Factory.MetaCode("something").Accepts(AcceptedCharactersInternal.None),
Factory.Span(SpanKindInternal.Code, " ", markup: false).Accepts(AcceptedCharactersInternal.WhiteSpace),
Factory.Span(SpanKindInternal.Code, "Else", markup: false).AsDirectiveToken(somethingDescriptor.Tokens[0])),
Factory.EmptyHtml()));
}
[Fact]
public void DirectiveDescriptor_TokensMustBeSeparatedBySpace()
{