mirror of
https://github.com/Facepunch/sbox-public.git
synced 2026-08-01 00:08:05 -04:00
ADDED css min(), max() and clamp() for lengths css-wide keywords inherit, initial, unset, revert (incl. shorthands) added currentColor keyword to css colors can parse oklch(), lab() and hwb() added css text-align: justify css white-space: pre-wrap and break-spaces added css word-break: break-word parses css image-rendering: crisp-edges (point sampling) added css inset shorthand now parses 1–4 values in the css border-width shorthand added css transition-duration, -delay, -property and -timing-function longhands css :has() now supports descendant selectors added css opacity percentages added css overflow: auto (maps to scroll) added css gap: normal added css filter: none css transform: scale() now takes comma-separated args css animations now accept ms units added more css justify-content, align-* and text-align keywords (start, end, …) parses css dvh/svh/lvh/dvw viewport units (treated as vh/vw) parses css object-fit: scale-down (treated as contain) added css margin-block / margin-inline (+ the logical margin sides) added css padding-block / padding-inline (+ the logical padding sides) added css inset-block / inset-inline (+ the logical inset sides) added the css flex-flow shorthand added the css font shorthand added css font-size keywords (xx-small … xxx-large) css letter-spacing and word-spacing now accept normal added css font-smooth: none css aspect-ratio now accepts the auto form css font-family now maps generic families (serif, sans-serif, monospace) added css flex-flow added css font FIXED fixed css !important dropping the whole declaration css line-height without units is now a multiplier fixed css calc() division and full-expression parsing fixed the css flex shorthand for single-number and three-value forms fixed the css transition shorthand when the property is omitted css font-family now uses the first family in a comma stack colors now parse fully in the css background shorthand an unknown css @-rule no longer drops the whole stylesheet css variables no longer match on partial tokens injected css variables are kept across stylesheet hotloads fixed css selector specificity overflowing when comparing rules css background: none now resets the background (image and colour) css filter: none now overrides a filter set by a base class css transform: none now overrides a transform set by a base class IMPROVED css rule matching is ~3–5× faster on large stylesheets (rules indexed by class) ~2× fewer allocations in css transitions (snapshot styles once, not per property) finished css animations no longer re-layout every frame less GC in css sibling/child selector queries (no list copies or wrappers) less GC diffing active css rules (no LINQ or hashsets) faster, lower-GC Yoga layout wrapper css stylesheet hotload now watches newly imported files
539 lines
14 KiB
C#
539 lines
14 KiB
C#
using Sandbox.Engine;
|
|
|
|
namespace Sandbox.UI;
|
|
|
|
internal static partial class StyleParser
|
|
{
|
|
[ThreadStatic]
|
|
static int IncludeLoops = 0;
|
|
|
|
public static StyleSheet ParseSheet( string content, string filename = "none", IEnumerable<(string, string)> variables = null, bool recover = false )
|
|
{
|
|
IncludeLoops = 0;
|
|
|
|
StyleSheet sheet = new();
|
|
sheet.AddVariables( variables );
|
|
|
|
ParseToSheet( content, filename, sheet, recover );
|
|
|
|
sheet.BuildIndex();
|
|
|
|
return sheet;
|
|
}
|
|
|
|
private static void ParseToSheet( string content, string filename, StyleSheet sheet, bool recover = false )
|
|
{
|
|
IncludeLoops++;
|
|
|
|
filename ??= "none";
|
|
filename = filename.NormalizeFilename();
|
|
|
|
sheet.AddFilename( filename );
|
|
|
|
content = StripComments( content );
|
|
|
|
var p = new Parse( content, filename );
|
|
while ( !p.IsEnd )
|
|
{
|
|
p = p.SkipWhitespaceAndNewlines();
|
|
|
|
if ( p.IsEnd )
|
|
break;
|
|
|
|
//
|
|
// When recovery is enabled we isolate parse failures to this single construct, so one
|
|
// unsupported selector, bad property, or unknown at-rule (eg @media) doesn't throw away
|
|
// every other rule in the stylesheet.
|
|
//
|
|
var blockStart = p.Pointer;
|
|
var nodeCountBefore = sheet.Nodes.Count;
|
|
|
|
try
|
|
{
|
|
if ( ParseVariable( ref p, sheet ) )
|
|
continue;
|
|
|
|
if ( ParseKeyframes( ref p, sheet ) )
|
|
continue;
|
|
|
|
if ( ParseMixinDefinition( ref p, sheet ) )
|
|
continue;
|
|
|
|
if ( ParseImport( ref p, sheet, filename, recover ) )
|
|
continue;
|
|
|
|
// Handle top-level @include (emits rules without parent selector)
|
|
if ( ParseTopLevelInclude( ref p, sheet ) )
|
|
continue;
|
|
|
|
var selector = p.ReadUntilOrEnd( "{;$@" );
|
|
|
|
if ( selector is null )
|
|
throw new System.Exception( $"Parse Error, expected class name {p.FileAndLine}" );
|
|
|
|
if ( p.IsEnd ) throw new System.Exception( $"Parse Error, unexpected end {p.FileAndLine}" );
|
|
|
|
if ( p.Current != '{' ) throw new System.Exception( $"Parse Error, unexpected character {p.Current} {p.FileAndLine}" );
|
|
|
|
ReadStyleBlock( ref p, selector, sheet, null );
|
|
}
|
|
catch ( System.Exception e ) when ( recover )
|
|
{
|
|
// Make the construct all-or-nothing: drop any nodes that were added before the failure.
|
|
if ( sheet.Nodes.Count > nodeCountBefore )
|
|
sheet.Nodes.RemoveRange( nodeCountBefore, sheet.Nodes.Count - nodeCountBefore );
|
|
|
|
// Skip past the malformed construct so the rest of the sheet still parses.
|
|
p.Pointer = SkipMalformedBlock( content, blockStart );
|
|
|
|
Log.Warning( $"Skipping malformed rule in {filename}: {e.Message}" );
|
|
}
|
|
}
|
|
|
|
IncludeLoops--;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Used to recover from a parse error in a single rule: returns the index just past the end of the
|
|
/// (possibly malformed) construct starting at <paramref name="start"/> so parsing can continue with
|
|
/// the next rule. Comments have already been stripped, so we only need to respect quoted strings and
|
|
/// brace nesting. Always returns a value greater than <paramref name="start"/> to guarantee progress.
|
|
/// </summary>
|
|
private static int SkipMalformedBlock( string text, int start )
|
|
{
|
|
int len = text.Length;
|
|
int i = start;
|
|
char quote = '\0';
|
|
|
|
// Phase 1: find the opening brace. If a ; appears first the construct is a statement with no block.
|
|
while ( i < len )
|
|
{
|
|
char c = text[i];
|
|
|
|
if ( quote != '\0' )
|
|
{
|
|
if ( c == quote ) quote = '\0';
|
|
}
|
|
else if ( c == '"' || c == '\'' ) quote = c;
|
|
else if ( c == ';' ) return i + 1;
|
|
else if ( c == '{' ) break;
|
|
|
|
i++;
|
|
}
|
|
|
|
if ( i >= len )
|
|
return len;
|
|
|
|
// Phase 2: brace-match from the opening brace to its (possibly nested) close.
|
|
int depth = 0;
|
|
quote = '\0';
|
|
|
|
while ( i < len )
|
|
{
|
|
char c = text[i];
|
|
|
|
if ( quote != '\0' )
|
|
{
|
|
if ( c == quote ) quote = '\0';
|
|
}
|
|
else if ( c == '"' || c == '\'' ) quote = c;
|
|
else if ( c == '{' ) depth++;
|
|
else if ( c == '}' )
|
|
{
|
|
depth--;
|
|
if ( depth == 0 ) return i + 1;
|
|
}
|
|
|
|
i++;
|
|
}
|
|
|
|
return len;
|
|
}
|
|
|
|
private static bool ParseVariable( ref Parse p, StyleSheet sheet )
|
|
{
|
|
if ( p.Current != '$' )
|
|
return false;
|
|
|
|
// We want the key with the $
|
|
(string key, string value) = p.ReadKeyValue();
|
|
|
|
bool isDefault = value.EndsWith( "!default", StringComparison.OrdinalIgnoreCase );
|
|
if ( isDefault )
|
|
{
|
|
value = value[..^8].Trim();
|
|
}
|
|
|
|
// Console.WriteLine( $"Found [{key}] = [{value}] ({isDefault})" );
|
|
|
|
sheet.SetVariable( key, value, isDefault );
|
|
|
|
return true;
|
|
}
|
|
|
|
private static void TryImport( StyleSheet sheet, string filename, string includeFileAndLine, bool recover = false )
|
|
{
|
|
if ( !GlobalContext.Current.FileMount.FileExists( filename ) )
|
|
throw new System.Exception( $"Missing import {filename} ({includeFileAndLine})" );
|
|
|
|
var text = GlobalContext.Current.FileMount.ReadAllText( filename );
|
|
ParseToSheet( text, filename, sheet, recover );
|
|
}
|
|
|
|
private static bool ParseImport( ref Parse p, StyleSheet sheet, string filename, bool recover = false )
|
|
{
|
|
if ( p.Current != '@' )
|
|
return false;
|
|
|
|
// Don't consume if it's @mixin or @include
|
|
if ( p.Is( "@mixin", 0, true ) || p.Is( "@include", 0, true ) )
|
|
return false;
|
|
|
|
var word = p.ReadWord( " ", true );
|
|
|
|
if ( string.IsNullOrWhiteSpace( word ) )
|
|
throw new System.Exception( $"Expected word after @ {p.FileAndLine}" );
|
|
|
|
if ( word == "@import" )
|
|
{
|
|
if ( IncludeLoops > 10 )
|
|
throw new System.Exception( $"Possible infinite @import loop {p.FileAndLine}" );
|
|
|
|
var thisRoot = System.IO.Path.GetDirectoryName( filename );
|
|
var files = p.ReadUntilOrEnd( ";" );
|
|
|
|
if ( string.IsNullOrWhiteSpace( files ) )
|
|
throw new System.Exception( $"Expected files then ; after @import {p.FileAndLine}" );
|
|
|
|
// files could be
|
|
// 1. "file", "file", "file"
|
|
// 2. "file"
|
|
// 3. 'file'
|
|
|
|
foreach ( var file in files.Split( ',', StringSplitOptions.RemoveEmptyEntries ) )
|
|
{
|
|
var cleanFile = file.Trim( ' ', '\"', '\'' );
|
|
if ( cleanFile.StartsWith( "./" ) ) cleanFile = cleanFile.Substring( 2 );
|
|
|
|
while ( cleanFile.StartsWith( "../" ) || cleanFile.StartsWith( "..\\" ) )
|
|
{
|
|
thisRoot = System.IO.Path.GetDirectoryName( thisRoot );
|
|
cleanFile = cleanFile.Substring( 3 );
|
|
}
|
|
|
|
// if no extension clean it up as an include
|
|
if ( !System.IO.Path.HasExtension( cleanFile ) ) cleanFile = $"_{cleanFile}.scss";
|
|
|
|
// try to find file in local directory, if not found then fall back
|
|
var localPath = System.IO.Path.Combine( thisRoot, cleanFile ).ToLower();
|
|
if ( !GlobalContext.Current.FileMount.FileExists( localPath ) )
|
|
{
|
|
localPath = cleanFile.ToLower();
|
|
}
|
|
|
|
TryImport( sheet, localPath, p.FileAndLine, recover );
|
|
}
|
|
|
|
if ( p.Is( ';' ) )
|
|
p.Pointer++;
|
|
|
|
return true;
|
|
}
|
|
|
|
throw new System.Exception( $"Unknown rule {word} {p.FileAndLine}" );
|
|
}
|
|
|
|
private static bool ParseKeyframes( ref Parse p, StyleSheet sheet )
|
|
{
|
|
var keyframe = KeyFrames.Parse( ref p, sheet );
|
|
if ( keyframe == null )
|
|
return false;
|
|
|
|
sheet.AddKeyFrames( keyframe );
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle @include at the top level (outside any selector block).
|
|
/// This expands the mixin content and parses any resulting style blocks.
|
|
/// </summary>
|
|
private static bool ParseTopLevelInclude( ref Parse p, StyleSheet sheet )
|
|
{
|
|
if ( !p.Is( "@include", 0, true ) )
|
|
return false;
|
|
|
|
var startLine = p.CurrentLine;
|
|
var fileAndLine = p.FileAndLine;
|
|
|
|
// Read the full @include statement
|
|
var includeStatement = p.ReadUntilOrEnd( ";" );
|
|
if ( p.Current == ';' )
|
|
p.Pointer++;
|
|
|
|
if ( !TryExpandInclude( includeStatement, sheet, fileAndLine, out var mixinName, out var expandedContent ) )
|
|
throw new Exception( $"Failed to expand mixin in top-level @include {fileAndLine}" );
|
|
|
|
// Parse the expanded content as if it were part of the stylesheet
|
|
// This allows mixins to emit complete style blocks at the top level
|
|
var innerParse = new Parse( expandedContent, p.FileName, startLine );
|
|
|
|
while ( !innerParse.IsEnd )
|
|
{
|
|
innerParse = innerParse.SkipWhitespaceAndNewlines();
|
|
if ( innerParse.IsEnd )
|
|
break;
|
|
|
|
var selector = innerParse.ReadUntilOrEnd( "{" );
|
|
if ( string.IsNullOrWhiteSpace( selector ) )
|
|
break;
|
|
|
|
if ( innerParse.Current == '{' )
|
|
{
|
|
ReadStyleBlock( ref innerParse, selector, sheet, null );
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
static void ReadStyleBlock( ref Parse p, string selectors, StyleSheet sheet, StyleBlock parentNode )
|
|
{
|
|
if ( p.Current != '{' )
|
|
throw new System.Exception( $"Block doesn't start with {{ {p.FileAndLine}" );
|
|
|
|
p.Pointer++;
|
|
p = p.SkipWhitespaceAndNewlines();
|
|
|
|
var node = new StyleBlock();
|
|
node.FileName = p.FileName;
|
|
node.AbsolutePath = GlobalContext.Current.FileMount?.GetFullPath( p.FileName );
|
|
node.FileLine = p.CurrentLine;
|
|
node.SetSelector( selectors, parentNode );
|
|
|
|
var styles = new Styles();
|
|
|
|
while ( !p.IsEnd )
|
|
{
|
|
var content = p.ReadUntilOrEnd( ";{}" );
|
|
if ( content is null ) throw new System.Exception( $"Parse Error, expected class name {p.FileAndLine}" );
|
|
|
|
if ( p.Current == '{' )
|
|
{
|
|
// Check if this is an @include with a content block
|
|
var trimmedContent = content.TrimStart();
|
|
if ( trimmedContent.StartsWith( "@include", StringComparison.OrdinalIgnoreCase ) )
|
|
{
|
|
// This is @include mixin-name { ... } with a content block
|
|
var contentBlock = ReadBracedContent( ref p );
|
|
|
|
if ( TryExpandInclude( content, contentBlock, sheet, p.FileAndLine, out var mixinName, out var expandedContent ) )
|
|
{
|
|
// Pass 'node' as the context for nested selector resolution
|
|
ProcessExpandedMixinContent( expandedContent, styles, sheet, node, p.FileName, p.CurrentLine );
|
|
p = p.SkipWhitespaceAndNewlines();
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Regular nested selector block
|
|
ReadStyleBlock( ref p, content, sheet, node );
|
|
continue;
|
|
}
|
|
|
|
if ( p.Current == ';' )
|
|
{
|
|
// Check if this is an @include directive (without content block)
|
|
if ( TryExpandInclude( content, null, sheet, p.FileAndLine, out var mixinName, out var expandedContent ) )
|
|
{
|
|
// Pass 'node' as the context for nested selector resolution
|
|
ProcessExpandedMixinContent( expandedContent, styles, sheet, node, p.FileName, p.CurrentLine );
|
|
}
|
|
else
|
|
{
|
|
// Regular property
|
|
try
|
|
{
|
|
content = sheet.ReplaceVariables( content );
|
|
}
|
|
catch ( System.Exception e )
|
|
{
|
|
throw new System.Exception( $"{e.Message} {p.FileAndLine}" );
|
|
}
|
|
|
|
styles.SetInternal( content, p.FileName, p.CurrentLine );
|
|
}
|
|
|
|
p.Pointer++;
|
|
p = p.SkipWhitespaceAndNewlines();
|
|
}
|
|
|
|
if ( p.Current == '}' )
|
|
{
|
|
p.Pointer++;
|
|
node.Styles = styles;
|
|
|
|
// Only add this node if it's not empty
|
|
if ( !node.IsEmpty )
|
|
{
|
|
// Assign load order as we add so nested rules get distinct, source-ordered values
|
|
node.LoadOrder = sheet.Nodes.Count();
|
|
sheet.Nodes.Add( node );
|
|
}
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
throw new System.Exception( $"Unexpected end of block {p.FileAndLine}" );
|
|
}
|
|
|
|
/// <summary>
|
|
/// Process expanded mixin content, handling both properties and nested rules.
|
|
/// The currentNode parameter is the StyleBlock where the @include appears - used for selector resolution.
|
|
/// </summary>
|
|
private static void ProcessExpandedMixinContent( string content, Styles styles, StyleSheet sheet, StyleBlock currentNode, string fileName, int lineOffset )
|
|
{
|
|
var p = new Parse( content, fileName, lineOffset );
|
|
|
|
while ( !p.IsEnd )
|
|
{
|
|
p = p.SkipWhitespaceAndNewlines();
|
|
if ( p.IsEnd )
|
|
break;
|
|
|
|
// Determine if this is a property or a nested rule
|
|
// Properties: name: value;
|
|
// Nested rules: selector { ... }
|
|
//
|
|
// The tricky part is distinguishing "color: red;" from "&:hover { }"
|
|
// We need to look ahead to find whether { or ; comes first after the first :
|
|
|
|
if ( IsNestedSelector( p ) )
|
|
{
|
|
// This is a nested rule
|
|
var selector = p.ReadUntilOrEnd( "{" );
|
|
if ( p.Current == '{' )
|
|
{
|
|
ReadStyleBlock( ref p, selector.Trim(), sheet, currentNode );
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// This is a property or @include
|
|
var propertyName = p.ReadUntilOrEnd( ":;" );
|
|
|
|
if ( p.Current == ':' )
|
|
{
|
|
p.Pointer++; // skip ':'
|
|
p = p.SkipWhitespaceAndNewlines();
|
|
|
|
var value = p.ReadUntilOrEnd( ";}" );
|
|
|
|
if ( p.Current == ';' )
|
|
p.Pointer++;
|
|
|
|
var propertyContent = $"{propertyName}: {value}";
|
|
|
|
try
|
|
{
|
|
propertyContent = sheet.ReplaceVariables( propertyContent );
|
|
}
|
|
catch ( Exception e )
|
|
{
|
|
throw new Exception( $"{e.Message} {p.FileAndLine}" );
|
|
}
|
|
|
|
styles.SetInternal( propertyContent, fileName, lineOffset );
|
|
}
|
|
else if ( p.Current == ';' )
|
|
{
|
|
// Could be an @include
|
|
if ( TryExpandInclude( propertyName, sheet, p.FileAndLine, out _, out var nestedExpanded ) )
|
|
{
|
|
ProcessExpandedMixinContent( nestedExpanded, styles, sheet, currentNode, fileName, lineOffset );
|
|
}
|
|
p.Pointer++;
|
|
}
|
|
else
|
|
{
|
|
// End of content
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines if the current position in the parse is a nested selector
|
|
/// rather than a CSS property.
|
|
/// </summary>
|
|
private static bool IsNestedSelector( in Parse original )
|
|
{
|
|
// Work with a copy since Parse is a struct
|
|
var p = original;
|
|
|
|
// Scan ahead to see what comes first: a { that indicates a block, or a ; that indicates a property
|
|
// We need to handle pseudo-selectors like &:hover which contain :
|
|
|
|
int braceDepth = 0;
|
|
|
|
while ( !p.IsEnd )
|
|
{
|
|
var c = p.Current;
|
|
|
|
if ( c == '(' ) braceDepth++;
|
|
else if ( c == ')' ) braceDepth--;
|
|
else if ( braceDepth == 0 )
|
|
{
|
|
if ( c == '{' )
|
|
{
|
|
// Found a { before a ; - this is a nested selector
|
|
return true;
|
|
}
|
|
|
|
if ( c == ';' )
|
|
{
|
|
// Found a ; before a { - this is a property
|
|
return false;
|
|
}
|
|
}
|
|
|
|
p.Pointer++;
|
|
}
|
|
|
|
// End of content without finding either - treat as not a selector
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Read content within braces, handling nested braces correctly.
|
|
/// Advances p past the closing brace.
|
|
/// </summary>
|
|
private static string ReadBracedContent( ref Parse p )
|
|
{
|
|
if ( p.Current != '{' )
|
|
throw new Exception( $"Expected '{{' {p.FileAndLine}" );
|
|
|
|
p.Pointer++; // skip opening brace
|
|
|
|
int depth = 1;
|
|
int start = p.Pointer;
|
|
|
|
while ( !p.IsEnd && depth > 0 )
|
|
{
|
|
if ( p.Current == '{' ) depth++;
|
|
else if ( p.Current == '}' ) depth--;
|
|
|
|
if ( depth > 0 )
|
|
p.Pointer++;
|
|
}
|
|
|
|
var content = p.Text.Substring( start, p.Pointer - start );
|
|
|
|
if ( p.Current == '}' )
|
|
p.Pointer++; // skip closing brace
|
|
|
|
return content;
|
|
}
|
|
}
|