From 297cb6d3e99cdea207e9cafde3b4c39e7412f0a0 Mon Sep 17 00:00:00 2001 From: wixoa Date: Wed, 20 May 2026 00:22:07 -0400 Subject: [PATCH] Add a basic CSS parser --- .../Interface/Controls/ControlMap.cs | 5 +- OpenDreamClient/Interface/Css/CssAst.cs | 94 +++++++ OpenDreamClient/Interface/Css/CssLexer.cs | 129 +++++++++ OpenDreamClient/Interface/Css/CssParser.cs | 246 ++++++++++++++++++ 4 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 OpenDreamClient/Interface/Css/CssAst.cs create mode 100644 OpenDreamClient/Interface/Css/CssLexer.cs create mode 100644 OpenDreamClient/Interface/Css/CssParser.cs diff --git a/OpenDreamClient/Interface/Controls/ControlMap.cs b/OpenDreamClient/Interface/Controls/ControlMap.cs index 018b5c6435..64a6e26f3d 100644 --- a/OpenDreamClient/Interface/Controls/ControlMap.cs +++ b/OpenDreamClient/Interface/Controls/ControlMap.cs @@ -1,11 +1,11 @@ using System.Diagnostics.CodeAnalysis; using OpenDreamClient.Input; using OpenDreamClient.Interface.Controls.UI; +using OpenDreamClient.Interface.Css; using OpenDreamShared.Interface.Descriptors; using OpenDreamShared.Interface.DMF; using OpenDreamClient.Rendering; using OpenDreamShared.Dream; -using Robust.Client.Graphics; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Shared.Input; @@ -39,6 +39,9 @@ protected override void UpdateElementDescriptor() { }; UpdateViewRange(InterfaceManager.View); + + var cssRulesets = new CssParser(new CssLexer(MapDescriptor.Style.AsRaw())).Stylesheet(); + Logger.Info($"{cssRulesets.Count} rulesets parsed"); } public void UpdateViewRange(ViewRange view) { diff --git a/OpenDreamClient/Interface/Css/CssAst.cs b/OpenDreamClient/Interface/Css/CssAst.cs new file mode 100644 index 0000000000..a64f14ee41 --- /dev/null +++ b/OpenDreamClient/Interface/Css/CssAst.cs @@ -0,0 +1,94 @@ +using System.Text; + +namespace OpenDreamClient.Interface.Css; + +public interface ICssAst; +public interface ICssExpression; + +public sealed class CssRuleset(List selectors, List declarations) : ICssAst { + public readonly List Selectors = selectors; + public readonly List Declarations = declarations; +} + +#region Selectors + +public abstract class CssSelector : ICssAst { + public CssSelector? SubSelector { get; set; } +} + +public sealed class CssNameSelector(ReadOnlyMemory name) : CssSelector { + public readonly ReadOnlyMemory Name = name; + + public override string ToString() => Name.ToString(); +} + +public sealed class CssIdSelector(ReadOnlyMemory id) : CssSelector { + public readonly ReadOnlyMemory Id = id; + + public override string ToString() => $"#{Id}"; +} + +public sealed class CssClassSelector(ReadOnlyMemory @class) : CssSelector { + public readonly ReadOnlyMemory Class = @class; + + public override string ToString() => $".{Class}"; +} + +#endregion + +public sealed class CssDeclaration(ReadOnlyMemory property, ICssExpression value) : ICssAst { + public readonly ReadOnlyMemory Property = property; + public readonly ICssExpression Value = value; + + public override string ToString() { + return $"{Property}: {Value}"; + } +} + +#region Expressions + +public sealed class CssString(ReadOnlyMemory value) : ICssExpression { + public readonly ReadOnlyMemory Value = value; + + public override string ToString() => $"\"{Value}\""; +} + +public sealed class CssIdentifier(ReadOnlyMemory value) : ICssExpression { + public readonly ReadOnlyMemory Value = value; + + public override string ToString() => Value.ToString(); +} + +public sealed class CssHexColor(ReadOnlyMemory value) : ICssExpression { + public readonly ReadOnlyMemory Value = value; + + public override string ToString() => Value.ToString(); +} + +public sealed class CssNumber(ReadOnlyMemory value) : ICssExpression { + public readonly ReadOnlyMemory Value = value; + + public override string ToString() => Value.ToString(); +} + +public sealed class CssDimension(ReadOnlyMemory value) : ICssExpression { + public readonly ReadOnlyMemory Value = value; + + public override string ToString() => Value.ToString(); +} + +public sealed class CssExpressionGroup(List expressions) : ICssExpression { + public readonly List Expressions = expressions; + + public override string ToString() { + var strBuilder = new StringBuilder(); + foreach (var expr in Expressions) { + strBuilder.Append(expr); + strBuilder.Append(' '); + } + + return strBuilder.ToString(); + } +} + +#endregion diff --git a/OpenDreamClient/Interface/Css/CssLexer.cs b/OpenDreamClient/Interface/Css/CssLexer.cs new file mode 100644 index 0000000000..07d6743a92 --- /dev/null +++ b/OpenDreamClient/Interface/Css/CssLexer.cs @@ -0,0 +1,129 @@ +namespace OpenDreamClient.Interface.Css; + +public sealed class CssLexer(string source) { + private int _currentIndex; + + public enum TokenType { + EndOfSource, + Unknown, + Whitespace, + Semicolon, + Colon, + Comma, + Asterisk, + Period, + OpeningBrace, + ClosingBrace, + Identifier, + String, + Hash, + Number, + Dimension + } + + public readonly struct Token(TokenType type, ReadOnlyMemory text) { + public readonly TokenType Type = type; + public readonly ReadOnlyMemory Text = text; + } + + public Token GetNextToken() { + var tokenStart = _currentIndex; + var tokenType = AdvanceNextToken(); + var tokenText = source.AsMemory(tokenStart, _currentIndex - tokenStart); + + if (tokenType is TokenType.Identifier) { + tokenType = tokenText switch { + _ => TokenType.Identifier + }; + } + + return new Token(tokenType, tokenText); + } + + private TokenType AdvanceNextToken() { + if (_currentIndex >= source.Length) + return TokenType.EndOfSource; + + var c = source[_currentIndex]; + switch (c) { + case ';': Advance(); return TokenType.Semicolon; + case ':': Advance(); return TokenType.Colon; + case ',': Advance(); return TokenType.Comma; + case '*': Advance(); return TokenType.Asterisk; + case '.': Advance(); return TokenType.Period; + case '{': Advance(); return TokenType.OpeningBrace; + case '}': Advance(); return TokenType.ClosingBrace; + case '#': + do { + c = Advance(); + } while (IsIdentifierChar(c)); + + return TokenType.Hash; + case '"': + case '\'': + var terminator = c; + do { + c = Advance(); + } while (!IsNewline(c) && c != terminator); + + Advance(); + return TokenType.String; + default: + if (char.IsWhiteSpace(c) || IsNewline(c)) { + do { + c = Advance(); + } while (char.IsWhiteSpace(c) || IsNewline(c)); + + return TokenType.Whitespace; + } + + if (AdvanceIdentifier(c)) { + return TokenType.Identifier; + } else if (char.IsAsciiDigit(c) || c is '.') { + do { + c = Advance(); + } while (char.IsAsciiDigit(c)); + + if (c is '.') { + do { + c = Advance(); + } while (char.IsAsciiDigit(c)); + } + + return AdvanceIdentifier(c) ? TokenType.Dimension : TokenType.Number; + } + + return TokenType.Unknown; + } + } + + private char Advance() { + _currentIndex++; + + return _currentIndex < source.Length ? source[_currentIndex] : '\0'; + } + + private bool AdvanceText(string text) { + foreach (var c in text) { + if (Advance() != c) + return false; + } + + return true; + } + + private bool AdvanceIdentifier(char c) { + if (char.IsAsciiLetter(c) || c is '-' or '_') { + do { + c = Advance(); + } while (IsIdentifierChar(c)); + + return true; + } + + return false; + } + + private static bool IsNewline(char c) => c is '\n' or '\r' or '\f'; + private static bool IsIdentifierChar(char c) => char.IsAsciiLetterOrDigit(c) || c is '-' or '_'; +} diff --git a/OpenDreamClient/Interface/Css/CssParser.cs b/OpenDreamClient/Interface/Css/CssParser.cs new file mode 100644 index 0000000000..4b82d890f2 --- /dev/null +++ b/OpenDreamClient/Interface/Css/CssParser.cs @@ -0,0 +1,246 @@ +using Token = OpenDreamClient.Interface.Css.CssLexer.Token; +using TokenType = OpenDreamClient.Interface.Css.CssLexer.TokenType; + +namespace OpenDreamClient.Interface.Css; + +// https://www.w3.org/TR/CSS2/grammar.html used for reference +public sealed class CssParser(CssLexer lexer) { + private Token _current = lexer.GetNextToken(); + + public List Stylesheet() { + var rulesets = new List(); + + while (true) { + Whitespace(); + + // TODO: Media(), Page() + if (Ruleset() is { } ruleset) { + rulesets.Add(ruleset); + } else { + break; + } + } + + return rulesets; + } + + // selector { property: expression } + private CssRuleset? Ruleset() { + var selector = Selector(); + if (selector is null) + return null; + + var selectors = new List(); + while (true) { + selectors.Add(selector); + + if (!Check(TokenType.Comma)) + break; + + selector = Selector(); + if (selector is null) + throw new Exception("Expected a selector"); + } + + Whitespace(); + Consume(TokenType.OpeningBrace, "Expected '{'"); + Whitespace(); + + var declarations = new List(); + var declaration = Declaration(); + while (true) { + if (declaration is not null) + declarations.Add(declaration); + + Whitespace(); + if (!Check(TokenType.Semicolon)) + break; + + Whitespace(); + declaration = Declaration(); + } + + if (!Check(TokenType.ClosingBrace)) + throw new Exception("Expected '}'"); + + Whitespace(); + return new CssRuleset(selectors, declarations); + } + + #region Selectors + + private CssSelector? Selector() { + // TODO: Combinators + return SimpleSelector(); + } + + private CssSelector? SimpleSelector() { + var selectors = new Stack(); + var elementName = ElementName(); + if (elementName is not null) + selectors.Push(new CssNameSelector(elementName.Value)); + + while (true) { + if (_current.Type is TokenType.Hash) { + selectors.Push(new CssIdSelector(_current.Text)); + Next(); + } else if (Class() is { } classSelector) { + selectors.Push(classSelector); + } else if (Attribute() is { } attributeSelector) { + selectors.Push(attributeSelector); + } else if (Pseudo() is { } pseudoSelector) { + selectors.Push(pseudoSelector); + } else { + break; + } + } + + if (selectors.Count == 0) + return null; + + var selector = selectors.Pop(); + while (selectors.TryPop(out var parentSelector)) { + parentSelector.SubSelector = selector; + selector = parentSelector; + } + + return selector; + } + + private ReadOnlyMemory? ElementName() { + if (_current.Type is TokenType.Identifier or TokenType.Asterisk) { + var name = _current.Text; + + Next(); + return name; + } + + return null; + } + + // .class + private CssClassSelector? Class() { + if (!Check(TokenType.Period)) + return null; + if (_current.Type is not TokenType.Identifier) + throw new Exception("Expected a class name"); + + var @class = _current.Text; + + Next(); + return new CssClassSelector(@class); + } + + // identifier = value + // identifier includes "string" + // identifier |= value + private CssSelector? Attribute() { + // TODO + return null; + } + + // :identifier + // :function() + private CssSelector? Pseudo() { + // TODO + return null; + } + + #endregion + + // property: expression + // property: expression !important + private CssDeclaration? Declaration() { + if (Property() is not { } property) + return null; + + Whitespace(); + Consume(TokenType.Colon, "Expected ':'"); + Whitespace(); + + var expr = Expression(); + if (expr is null) + throw new Exception("Expected an expression"); + + // TODO: !important + + return new CssDeclaration(property, expr); + } + + private ReadOnlyMemory? Property() { + if (_current.Type is TokenType.Identifier) { + var property = _current.Text; + + Next(); + Whitespace(); + return property; + } + + return null; + } + + private ICssExpression? Expression() { + if (ExpressionTerm() is not { } firstTerm) + return null; + + // TODO: Operators + + if (ExpressionTerm() is not { } secondaryTerm) + return firstTerm; + + var terms = new List { firstTerm }; + + while (secondaryTerm is not null) { + terms.Add(secondaryTerm); + secondaryTerm = ExpressionTerm(); + } + + return new CssExpressionGroup(terms); + } + + private ICssExpression? ExpressionTerm() { + // TODO: Unary operator + + ICssExpression? term = _current.Type switch { + TokenType.String => new CssString(_current.Text), + TokenType.Identifier => new CssIdentifier(_current.Text), + TokenType.Hash => new CssHexColor(_current.Text), + TokenType.Number => new CssNumber(_current.Text), + TokenType.Dimension => new CssDimension(_current.Text), // TODO: Split into Length, Angle, Time, Frequency + // TODO: Percentage, em, ex, URI, Function + _ => null + }; + + if (term is null) + return null; + + Next(); + Whitespace(); + return term; + } + + #region Helpers + + private void Next() { + _current = lexer.GetNextToken(); + } + + private bool Check(TokenType type) { + if (_current.Type != type) + return false; + + Next(); + return true; + } + + private void Consume(TokenType type, string errorMessage) { + if (!Check(type)) + throw new Exception(errorMessage); + } + + private void Whitespace() { + while (Check(TokenType.Whitespace)) { } + } + + #endregion +}