From 527d5befc080b13097017c976b26caf6785c1342 Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Wed, 27 Nov 2024 14:20:38 -0400 Subject: [PATCH 01/42] feat: introduce css lexer This commit introduces a basic CSS lexer to parse CSS rules and extract tokens. The lexer can handle common CSS syntax, including: - Class and ID selectors - Properties and values - Hex color codes - URL values - Whitespace and comments The lexer generates a sequence of tokens, each representing a specific element in the CSS syntax. This sequence can then be used by a parser to build an abstract syntax tree (AST) for further processing. --- .../kotlin/dev/tonholo/s2c/lexer/Lexer.kt | 5 + .../kotlin/dev/tonholo/s2c/lexer/Token.kt | 9 + .../kotlin/dev/tonholo/s2c/lexer/TokenType.kt | 9 + .../dev/tonholo/s2c/lexer/css/CssLexer.kt | 208 ++++++++++++++++++ .../tonholo/s2c/lexer/css/CssTokenTypes.kt | 69 ++++++ .../dev/tonholo/s2c/lexer/css/CssLexerTest.kt | 184 ++++++++++++++++ 6 files changed, 484 insertions(+) create mode 100644 svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Lexer.kt create mode 100644 svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Token.kt create mode 100644 svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/TokenType.kt create mode 100644 svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssLexer.kt create mode 100644 svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssTokenTypes.kt create mode 100644 svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/lexer/css/CssLexerTest.kt diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Lexer.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Lexer.kt new file mode 100644 index 00000000..df2430ff --- /dev/null +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Lexer.kt @@ -0,0 +1,5 @@ +package dev.tonholo.s2c.lexer + +interface Lexer { + fun tokenize(input: String): Sequence> +} diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Token.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Token.kt new file mode 100644 index 00000000..ca600c25 --- /dev/null +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Token.kt @@ -0,0 +1,9 @@ +package dev.tonholo.s2c.lexer + +data class Token( + val type: T, + // Inclusive + val startOffset: Int, + // Exclusive + val endOffset: Int, +) diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/TokenType.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/TokenType.kt new file mode 100644 index 00000000..af149e98 --- /dev/null +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/TokenType.kt @@ -0,0 +1,9 @@ +package dev.tonholo.s2c.lexer + +interface TokenType { + val representation: String + + operator fun contains(other: Char): Boolean { + return other in representation + } +} diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssLexer.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssLexer.kt new file mode 100644 index 00000000..e381c7a6 --- /dev/null +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssLexer.kt @@ -0,0 +1,208 @@ +package dev.tonholo.s2c.lexer.css + +import dev.tonholo.s2c.extensions.EMPTY +import dev.tonholo.s2c.lexer.Lexer +import dev.tonholo.s2c.lexer.Token + +class CssLexer : Lexer { + private var offset = 0 + private var input = "" + + override fun tokenize(input: String): Sequence> = sequence { + offset = 0 + this@CssLexer.input = input + while (offset < input.length) { + val char = input[offset] + println("offset: $offset, char: $char") + when (char.lowercaseChar()) { + in CssTokenTypes.WhiteSpace -> { + yield(handleWhitespace(start = offset)) + } + + in CssTokenTypes.Dot -> { + yield(Token(CssTokenTypes.Dot, offset, offset + 1)) + offset++ + } + + in CssTokenTypes.Comma -> { + yield(Token(CssTokenTypes.Comma, offset, offset + 1)) + offset++ + } + + in CssTokenTypes.Colon -> { + yield(Token(CssTokenTypes.Colon, offset, offset + 1)) + offset++ + } + + in CssTokenTypes.Semicolon -> { + yield(Token(CssTokenTypes.Semicolon, offset, offset + 1)) + offset++ + } + + in CssTokenTypes.OpenCurlyBrace -> { + yield(Token(CssTokenTypes.OpenCurlyBrace, offset, offset + 1)) + offset++ + } + + in CssTokenTypes.CloseCurlyBrace -> { + yield(Token(CssTokenTypes.CloseCurlyBrace, offset, offset + 1)) + offset++ + } + + in CssTokenTypes.Hash -> { + val next = peek(1) + val token = if (next != Char.EMPTY && next.isHexDigit()) { + var backwardLookupOffset = 0 + do { + val prev = peek(--backwardLookupOffset) + if (prev == Char.EMPTY || prev != ' ') { + break + } + } while (prev == ' ') + + if (peek(backwardLookupOffset) in CssTokenTypes.Colon) { + yield(Token(CssTokenTypes.Hash, offset, ++offset)) + handleHexDigit(start = offset) + } else { + Token(CssTokenTypes.Hash, offset, ++offset) + } + } else { + Token(CssTokenTypes.Hash, offset, ++offset) + } + + yield(token) + } + + 'u' -> { + if (peek(offset = 1) == 'r' && peek(offset = 2) == 'l' && peek(offset = 3) == '(') { + yieldUrl(start = offset) + } + } + + in '0'..'9' -> { + yield(handleNumber(start = offset)) + } + + else -> { + yield(handleIdentifier(start = offset)) + } + } + } + yield(Token(CssTokenTypes.EndOfFile, offset, offset)) + } + + private fun handleWhitespace(start: Int): Token { + offset++ + while (offset < input.length) { + val char = input[offset] + if (char !in CssTokenTypes.WhiteSpace) { + break + } + offset++ + } + + return Token(CssTokenTypes.WhiteSpace, start, offset) + } + + private fun handleIdentifier(start: Int): Token { + while (offset < input.length) { + val char = input[offset] + when (char) { + in '0'..'9', + in 'a'..'z', + in 'A'..'Z', + '-', + '_' -> { + offset++ + } + + else -> { + break + } + } + } + + return Token(CssTokenTypes.Identifier, start, offset) + } + + private fun handleNumber(start: Int): Token { + while (offset < input.length) { + val char = input[offset] + when (char.lowercaseChar()) { + '.', + 'e', + '+', + '-', + in '0'..'9' -> { + offset++ + } + + else -> { + break + } + } + } + + return Token(CssTokenTypes.Number, start, offset) + } + + private suspend fun SequenceScope>.yieldUrl(start: Int) { + val urlContentStart = advance(steps = 4) + yield(Token(CssTokenTypes.StartUrl, start, offset)) + while (offset < input.length && input[offset] !in CssTokenTypes.EndUrl) { + offset++ + } + + + val urlContent = input.substring(startIndex = urlContentStart, offset) + yieldAll( + // TODO: find a better way for that. + CssLexer() + .tokenize(urlContent) + .map { + it.copy( + startOffset = urlContentStart + it.startOffset, + endOffset = urlContentStart + it.endOffset, + ) + } + .filterNot { it.type == CssTokenTypes.EndOfFile } + ) + yield(Token(CssTokenTypes.EndUrl, offset, ++offset)) + } + + private fun handleHexDigit(start: Int): Token { + while (offset < input.length) { + val char = input[offset] + when (char.lowercaseChar()) { + in '0'..'9', + in 'a'..'f' -> { + offset++ + } + + else -> break + } + } + + return Token(CssTokenTypes.HexDigit, start, offset) + } + + private fun peek(offset: Int): Char = if (this.offset + offset >= input.length) { + Char.EMPTY + } else { + val index = this.offset + offset + input[index] + } + + private fun advance(steps: Int = 1): Int { + offset += steps + return offset + } + + private fun backward(steps: Int = 1) { + offset -= steps + } + + private fun Char.isHexDigit(): Boolean { + return this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F' + } +} diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssTokenTypes.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssTokenTypes.kt new file mode 100644 index 00000000..d8742a78 --- /dev/null +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssTokenTypes.kt @@ -0,0 +1,69 @@ +package dev.tonholo.s2c.lexer.css + +import dev.tonholo.s2c.lexer.TokenType + +sealed class CssTokenTypes( + override val representation: String, +) : TokenType { + data object EndOfFile : CssTokenTypes(representation = "") + data object WhiteSpace : CssTokenTypes(representation = " \n\t") + data object At : CssTokenTypes(representation = "@") + data object Dot : CssTokenTypes(representation = ".") + data object Asterisk : CssTokenTypes(representation = "*") + data object Ampersand : CssTokenTypes(representation = "&") + data object Tilde : CssTokenTypes(representation = "~") + data object Equals : CssTokenTypes(representation = "=") + data object Plus : CssTokenTypes(representation = "+") + data object Minus : CssTokenTypes(representation = "-") + data object Colon : CssTokenTypes(representation = ":") + data object Semicolon : CssTokenTypes(representation = ";") + data object Comma : CssTokenTypes(representation = ",") + data object Greater : CssTokenTypes(representation = ">") + data object Or : CssTokenTypes(representation = "|") + data object Caret : CssTokenTypes(representation = "^") + data object Dollar : CssTokenTypes(representation = "$") + data object Slash : CssTokenTypes(representation = "/") + data object Percent : CssTokenTypes(representation = "%") + data object Bang : CssTokenTypes(representation = "!") + data object Identifier : CssTokenTypes(representation = "") + data object Hash : CssTokenTypes(representation = "#") + + // Decimal or float + data object Number : CssTokenTypes(representation = "") + data object HexDigit : CssTokenTypes(representation = "") + data object StringLiteral : CssTokenTypes(representation = "") + data object MultilineString : CssTokenTypes(representation = "") + + // Missing end quote, mismatched quotes (missing start quote will yield one or more identifiers) + data object InvalidString : CssTokenTypes(representation = "") + + // "URL(" string - note that space between URL and ( is not allowed + data object StartUrl : CssTokenTypes(representation = "url(") + data object EndUrl : CssTokenTypes(representation = ")") + + // Unquoted URL + data object UnquotedUrlString : CssTokenTypes(representation = "") + data object OneOf : CssTokenTypes(representation = "~=") + data object ContainsString : CssTokenTypes(representation = "*=") + data object EndsWith : CssTokenTypes(representation = "$") + data object BeginsWith : CssTokenTypes(representation = "^=") + data object ListBeginsWith : CssTokenTypes(representation = "|=") + data object DoubleColon : CssTokenTypes(representation = "::") + data object DoublePipe : CssTokenTypes(representation = "||") + data object OpenCurlyBrace : CssTokenTypes(representation = "{") + data object CloseCurlyBrace : CssTokenTypes(representation = "}") + data object OpenSquareBracket : CssTokenTypes(representation = "[") + data object CloseSquareBracket : CssTokenTypes(representation = "]") + data object OpenFunctionBrace : CssTokenTypes(representation = "(") + data object CloseFunctionBrace : CssTokenTypes(representation = ")") + data object HtmlComment : CssTokenTypes(representation = "") + data object MultilineComment : CssTokenTypes(representation = "") + data object SingleComment : CssTokenTypes(representation = "") + data object Function : CssTokenTypes(representation = "") + data object Units : CssTokenTypes(representation = "") + data object Quote : CssTokenTypes(representation = "'") + data object DoubleQuote : CssTokenTypes(representation = "\"") + + // U+4E00-9FFF, U+30?? see https://www.w3.org/TR/css-fonts-3/#unicode-range-desc + data object UnicodeRange : CssTokenTypes(representation = "u") +} diff --git a/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/lexer/css/CssLexerTest.kt b/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/lexer/css/CssLexerTest.kt new file mode 100644 index 00000000..5c8fac99 --- /dev/null +++ b/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/lexer/css/CssLexerTest.kt @@ -0,0 +1,184 @@ +package dev.tonholo.s2c.lexer.css + +import dev.tonholo.s2c.lexer.Token +import kotlin.test.Test +import kotlin.test.assertEquals + +class CssLexerTest { + @Test + fun `create tokens for a class css rule`() { + val input = """ + |.my-rule { + | background: #f0f; + | color: #000; + |} + """.trimMargin() + + val expected = sequenceOf( + Token(type = CssTokenTypes.Dot, startOffset = 0, endOffset = 1), + Token(type = CssTokenTypes.Identifier, startOffset = 1, endOffset = 8), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 8, endOffset = 9), + Token(type = CssTokenTypes.OpenCurlyBrace, startOffset = 9, endOffset = 10), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 10, endOffset = 15), + Token(type = CssTokenTypes.Identifier, startOffset = 15, endOffset = 25), + Token(type = CssTokenTypes.Colon, startOffset = 25, endOffset = 26), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 26, endOffset = 27), + Token(type = CssTokenTypes.Hash, startOffset = 27, endOffset = 28), + Token(type = CssTokenTypes.HexDigit, startOffset = 28, endOffset = 31), + Token(type = CssTokenTypes.Semicolon, startOffset = 31, endOffset = 32), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 32, endOffset = 37), + Token(type = CssTokenTypes.Identifier, startOffset = 37, endOffset = 42), + Token(type = CssTokenTypes.Colon, startOffset = 42, endOffset = 43), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 43, endOffset = 44), + Token(type = CssTokenTypes.Hash, startOffset = 44, endOffset = 45), + Token(type = CssTokenTypes.HexDigit, startOffset = 45, endOffset = 48), + Token(type = CssTokenTypes.Semicolon, startOffset = 48, endOffset = 49), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 49, endOffset = 50), + Token(type = CssTokenTypes.CloseCurlyBrace, startOffset = 50, endOffset = 51), + Token(type = CssTokenTypes.EndOfFile, startOffset = 51, endOffset = 51), + ).toList() + + val lexer = CssLexer() + val actual = lexer.tokenize(input).toList() + assertEquals(expected, actual) + } + + @Test + fun `create tokens for an id css rule`() { + val input = """ + |#my-rule { + | background: #f0f; + | color: #000; + |} + """.trimMargin() + + val expected = sequenceOf( + Token(type = CssTokenTypes.Hash, startOffset = 0, endOffset = 1), + Token(type = CssTokenTypes.Identifier, startOffset = 1, endOffset = 8), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 8, endOffset = 9), + Token(type = CssTokenTypes.OpenCurlyBrace, startOffset = 9, endOffset = 10), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 10, endOffset = 15), + Token(type = CssTokenTypes.Identifier, startOffset = 15, endOffset = 25), + Token(type = CssTokenTypes.Colon, startOffset = 25, endOffset = 26), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 26, endOffset = 27), + Token(type = CssTokenTypes.Hash, startOffset = 27, endOffset = 28), + Token(type = CssTokenTypes.HexDigit, startOffset = 28, endOffset = 31), + Token(type = CssTokenTypes.Semicolon, startOffset = 31, endOffset = 32), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 32, endOffset = 37), + Token(type = CssTokenTypes.Identifier, startOffset = 37, endOffset = 42), + Token(type = CssTokenTypes.Colon, startOffset = 42, endOffset = 43), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 43, endOffset = 44), + Token(type = CssTokenTypes.Hash, startOffset = 44, endOffset = 45), + Token(type = CssTokenTypes.HexDigit, startOffset = 45, endOffset = 48), + Token(type = CssTokenTypes.Semicolon, startOffset = 48, endOffset = 49), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 49, endOffset = 50), + Token(type = CssTokenTypes.CloseCurlyBrace, startOffset = 50, endOffset = 51), + Token(type = CssTokenTypes.EndOfFile, startOffset = 51, endOffset = 51), + ).toList() + + val lexer = CssLexer() + val actual = lexer.tokenize(input).toList() + assertEquals(expected, actual) + } + + @Test + fun `create tokens for css style with multiple rules`() { + val input = """ + |.my-rule { + | background: #f0f; + | color: #000; + |} + | + |#my-rule { + | background: #0ff; + | margin-bottom: 1px; + |} + """.trimMargin() + + val expected = sequenceOf( + Token(type = CssTokenTypes.Dot, startOffset = 0, endOffset = 1), + Token(type = CssTokenTypes.Identifier, startOffset = 1, endOffset = 8), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 8, endOffset = 9), + Token(type = CssTokenTypes.OpenCurlyBrace, startOffset = 9, endOffset = 10), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 10, endOffset = 15), + Token(type = CssTokenTypes.Identifier, startOffset = 15, endOffset = 25), + Token(type = CssTokenTypes.Colon, startOffset = 25, endOffset = 26), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 26, endOffset = 27), + Token(type = CssTokenTypes.Hash, startOffset = 27, endOffset = 28), + Token(type = CssTokenTypes.HexDigit, startOffset = 28, endOffset = 31), + Token(type = CssTokenTypes.Semicolon, startOffset = 31, endOffset = 32), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 32, endOffset = 37), + Token(type = CssTokenTypes.Identifier, startOffset = 37, endOffset = 42), + Token(type = CssTokenTypes.Colon, startOffset = 42, endOffset = 43), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 43, endOffset = 44), + Token(type = CssTokenTypes.Hash, startOffset = 44, endOffset = 45), + Token(type = CssTokenTypes.HexDigit, startOffset = 45, endOffset = 48), + Token(type = CssTokenTypes.Semicolon, startOffset = 48, endOffset = 49), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 49, endOffset = 50), + Token(type = CssTokenTypes.CloseCurlyBrace, startOffset = 50, endOffset = 51), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 51, endOffset = 53), + + Token(type = CssTokenTypes.Hash, startOffset = 53, endOffset = 54), + Token(type = CssTokenTypes.Identifier, startOffset = 54, endOffset = 61), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 61, endOffset = 62), + Token(type = CssTokenTypes.OpenCurlyBrace, startOffset = 62, endOffset = 63), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 63, endOffset = 68), + Token(type = CssTokenTypes.Identifier, startOffset = 68, endOffset = 78), + Token(type = CssTokenTypes.Colon, startOffset = 78, endOffset = 79), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 79, endOffset = 80), + Token(type = CssTokenTypes.Hash, startOffset = 80, endOffset = 81), + Token(type = CssTokenTypes.HexDigit, startOffset = 81, endOffset = 84), + Token(type = CssTokenTypes.Semicolon, startOffset = 84, endOffset = 85), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 85, endOffset = 90), + Token(type = CssTokenTypes.Identifier, startOffset = 90, endOffset = 103), + Token(type = CssTokenTypes.Colon, startOffset = 103, endOffset = 104), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 104, endOffset = 105), + Token(type = CssTokenTypes.Number, startOffset = 105, endOffset = 106), + Token(type = CssTokenTypes.Identifier, startOffset = 106, endOffset = 108), + Token(type = CssTokenTypes.Semicolon, startOffset = 108, endOffset = 109), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 109, endOffset = 110), + Token(type = CssTokenTypes.CloseCurlyBrace, startOffset = 110, endOffset = 111), + Token(type = CssTokenTypes.EndOfFile, startOffset = 111, endOffset = 111), + ).toList() + + val lexer = CssLexer() + val actual = lexer.tokenize(input).toList() + assertEquals(expected, actual) + } + + @Test + fun `create tokens for css with multiple selectors rule`() { + val input = """ + |#my-rule, .my-class { + | clip-path: url(#my-clip-path); + |} + """.trimMargin() + + val expected = sequenceOf( + Token(type = CssTokenTypes.Hash, startOffset = 0, endOffset = 1), + Token(type = CssTokenTypes.Identifier, startOffset = 1, endOffset = 8), + Token(type = CssTokenTypes.Comma, startOffset = 8, endOffset = 9), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 9, endOffset = 10), + Token(type = CssTokenTypes.Dot, startOffset = 10, endOffset = 11), + Token(type = CssTokenTypes.Identifier, startOffset = 11, endOffset = 19), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 19, endOffset = 20), + Token(type = CssTokenTypes.OpenCurlyBrace, startOffset = 20, endOffset = 21), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 21, endOffset = 26), + Token(type = CssTokenTypes.Identifier, startOffset = 26, endOffset = 35), + Token(type = CssTokenTypes.Colon, startOffset = 35, endOffset = 36), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 36, endOffset = 37), + Token(type = CssTokenTypes.StartUrl, startOffset = 37, endOffset = 41), + Token(type = CssTokenTypes.Hash, startOffset = 41, endOffset = 42), + Token(type = CssTokenTypes.Identifier, startOffset = 42, endOffset = 54), + Token(type = CssTokenTypes.EndUrl, startOffset = 54, endOffset = 55), + Token(type = CssTokenTypes.Semicolon, startOffset = 55, endOffset = 56), + Token(type = CssTokenTypes.WhiteSpace, startOffset = 56, endOffset = 57), + Token(type = CssTokenTypes.CloseCurlyBrace, startOffset = 57, endOffset = 58), + Token(type = CssTokenTypes.EndOfFile, startOffset = 58, endOffset = 58), + ).toList() + + val lexer = CssLexer() + val actual = lexer.tokenize(input).toList() + assertEquals(expected, actual) + } +} From bbd1449c263c4f7d2e4187eaa3bc40a6857588f1 Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Wed, 27 Nov 2024 14:26:23 -0400 Subject: [PATCH 02/42] feat: introduce css lexer This commit introduces a basic CSS lexer to parse CSS rules and extract tokens. The lexer can handle common CSS syntax, including: - Class and ID selectors - Properties and values - Hex color codes - URL values - Whitespace and comments The lexer generates a sequence of tokens, each representing a specific element in the CSS syntax. This sequence can then be used by a parser to build an abstract syntax tree (AST) for further processing. --- .../kotlin/dev/tonholo/s2c/lexer/Lexer.kt | 2 +- .../kotlin/dev/tonholo/s2c/lexer/Token.kt | 4 +- .../s2c/lexer/{TokenType.kt => TokenKind.kt} | 2 +- .../dev/tonholo/s2c/lexer/css/CssLexer.kt | 82 +++--- .../dev/tonholo/s2c/lexer/css/CssTokenKind.kt | 69 +++++ .../tonholo/s2c/lexer/css/CssTokenTypes.kt | 69 ----- .../dev/tonholo/s2c/lexer/css/CssLexerTest.kt | 255 ++++++++++-------- 7 files changed, 257 insertions(+), 226 deletions(-) rename svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/{TokenType.kt => TokenKind.kt} (83%) create mode 100644 svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssTokenKind.kt delete mode 100644 svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssTokenTypes.kt diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Lexer.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Lexer.kt index df2430ff..7a61c90a 100644 --- a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Lexer.kt +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Lexer.kt @@ -1,5 +1,5 @@ package dev.tonholo.s2c.lexer -interface Lexer { +internal interface Lexer { fun tokenize(input: String): Sequence> } diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Token.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Token.kt index ca600c25..8f654276 100644 --- a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Token.kt +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/Token.kt @@ -1,7 +1,7 @@ package dev.tonholo.s2c.lexer -data class Token( - val type: T, +internal data class Token( + val kind: T, // Inclusive val startOffset: Int, // Exclusive diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/TokenType.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/TokenKind.kt similarity index 83% rename from svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/TokenType.kt rename to svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/TokenKind.kt index af149e98..ba166875 100644 --- a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/TokenType.kt +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/TokenKind.kt @@ -1,6 +1,6 @@ package dev.tonholo.s2c.lexer -interface TokenType { +internal interface TokenKind { val representation: String operator fun contains(other: Char): Boolean { diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssLexer.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssLexer.kt index e381c7a6..cef19e37 100644 --- a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssLexer.kt +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssLexer.kt @@ -4,52 +4,52 @@ import dev.tonholo.s2c.extensions.EMPTY import dev.tonholo.s2c.lexer.Lexer import dev.tonholo.s2c.lexer.Token -class CssLexer : Lexer { +internal class CssLexer : Lexer { private var offset = 0 private var input = "" - override fun tokenize(input: String): Sequence> = sequence { + override fun tokenize(input: String): Sequence> = sequence { offset = 0 this@CssLexer.input = input while (offset < input.length) { val char = input[offset] println("offset: $offset, char: $char") when (char.lowercaseChar()) { - in CssTokenTypes.WhiteSpace -> { + in CssTokenKind.WhiteSpace -> { yield(handleWhitespace(start = offset)) } - in CssTokenTypes.Dot -> { - yield(Token(CssTokenTypes.Dot, offset, offset + 1)) + in CssTokenKind.Dot -> { + yield(Token(CssTokenKind.Dot, offset, offset + 1)) offset++ } - in CssTokenTypes.Comma -> { - yield(Token(CssTokenTypes.Comma, offset, offset + 1)) + in CssTokenKind.Comma -> { + yield(Token(CssTokenKind.Comma, offset, offset + 1)) offset++ } - in CssTokenTypes.Colon -> { - yield(Token(CssTokenTypes.Colon, offset, offset + 1)) + in CssTokenKind.Colon -> { + yield(Token(CssTokenKind.Colon, offset, offset + 1)) offset++ } - in CssTokenTypes.Semicolon -> { - yield(Token(CssTokenTypes.Semicolon, offset, offset + 1)) + in CssTokenKind.Semicolon -> { + yield(Token(CssTokenKind.Semicolon, offset, offset + 1)) offset++ } - in CssTokenTypes.OpenCurlyBrace -> { - yield(Token(CssTokenTypes.OpenCurlyBrace, offset, offset + 1)) + in CssTokenKind.OpenCurlyBrace -> { + yield(Token(CssTokenKind.OpenCurlyBrace, offset, offset + 1)) offset++ } - in CssTokenTypes.CloseCurlyBrace -> { - yield(Token(CssTokenTypes.CloseCurlyBrace, offset, offset + 1)) + in CssTokenKind.CloseCurlyBrace -> { + yield(Token(CssTokenKind.CloseCurlyBrace, offset, offset + 1)) offset++ } - in CssTokenTypes.Hash -> { + in CssTokenKind.Hash -> { val next = peek(1) val token = if (next != Char.EMPTY && next.isHexDigit()) { var backwardLookupOffset = 0 @@ -60,14 +60,14 @@ class CssLexer : Lexer { } } while (prev == ' ') - if (peek(backwardLookupOffset) in CssTokenTypes.Colon) { - yield(Token(CssTokenTypes.Hash, offset, ++offset)) + if (peek(backwardLookupOffset) in CssTokenKind.Colon) { + yield(Token(CssTokenKind.Hash, offset, ++offset)) handleHexDigit(start = offset) } else { - Token(CssTokenTypes.Hash, offset, ++offset) + Token(CssTokenKind.Hash, offset, ++offset) } } else { - Token(CssTokenTypes.Hash, offset, ++offset) + Token(CssTokenKind.Hash, offset, ++offset) } yield(token) @@ -88,23 +88,23 @@ class CssLexer : Lexer { } } } - yield(Token(CssTokenTypes.EndOfFile, offset, offset)) + yield(Token(CssTokenKind.EndOfFile, offset, offset)) } - private fun handleWhitespace(start: Int): Token { + private fun handleWhitespace(start: Int): Token { offset++ while (offset < input.length) { val char = input[offset] - if (char !in CssTokenTypes.WhiteSpace) { + if (char !in CssTokenKind.WhiteSpace) { break } offset++ } - return Token(CssTokenTypes.WhiteSpace, start, offset) + return Token(CssTokenKind.WhiteSpace, start, offset) } - private fun handleIdentifier(start: Int): Token { + private fun handleIdentifier(start: Int): Token { while (offset < input.length) { val char = input[offset] when (char) { @@ -122,10 +122,10 @@ class CssLexer : Lexer { } } - return Token(CssTokenTypes.Identifier, start, offset) + return Token(CssTokenKind.Identifier, start, offset) } - private fun handleNumber(start: Int): Token { + private fun handleNumber(start: Int): Token { while (offset < input.length) { val char = input[offset] when (char.lowercaseChar()) { @@ -143,13 +143,13 @@ class CssLexer : Lexer { } } - return Token(CssTokenTypes.Number, start, offset) + return Token(CssTokenKind.Number, start, offset) } - private suspend fun SequenceScope>.yieldUrl(start: Int) { + private suspend fun SequenceScope>.yieldUrl(start: Int) { val urlContentStart = advance(steps = 4) - yield(Token(CssTokenTypes.StartUrl, start, offset)) - while (offset < input.length && input[offset] !in CssTokenTypes.EndUrl) { + yield(Token(CssTokenKind.StartUrl, start, offset)) + while (offset < input.length && input[offset] !in CssTokenKind.EndUrl) { offset++ } @@ -165,12 +165,12 @@ class CssLexer : Lexer { endOffset = urlContentStart + it.endOffset, ) } - .filterNot { it.type == CssTokenTypes.EndOfFile } + .filterNot { it.kind == CssTokenKind.EndOfFile } ) - yield(Token(CssTokenTypes.EndUrl, offset, ++offset)) + yield(Token(CssTokenKind.EndUrl, offset, ++offset)) } - private fun handleHexDigit(start: Int): Token { + private fun handleHexDigit(start: Int): Token { while (offset < input.length) { val char = input[offset] when (char.lowercaseChar()) { @@ -183,14 +183,16 @@ class CssLexer : Lexer { } } - return Token(CssTokenTypes.HexDigit, start, offset) + return Token(CssTokenKind.HexDigit, start, offset) } - private fun peek(offset: Int): Char = if (this.offset + offset >= input.length) { - Char.EMPTY - } else { - val index = this.offset + offset - input[index] + private fun peek(offset: Int): Char { + val peekIndex = this.offset + offset + return if (peekIndex < 0 || peekIndex >= input.length) { + Char.EMPTY + } else { + input[peekIndex] + } } private fun advance(steps: Int = 1): Int { diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssTokenKind.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssTokenKind.kt new file mode 100644 index 00000000..89b7f537 --- /dev/null +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssTokenKind.kt @@ -0,0 +1,69 @@ +package dev.tonholo.s2c.lexer.css + +import dev.tonholo.s2c.lexer.TokenKind + +internal sealed class CssTokenKind( + override val representation: String, +) : TokenKind { + data object EndOfFile : CssTokenKind(representation = "") + data object WhiteSpace : CssTokenKind(representation = " \n\t") + data object At : CssTokenKind(representation = "@") + data object Dot : CssTokenKind(representation = ".") + data object Asterisk : CssTokenKind(representation = "*") + data object Ampersand : CssTokenKind(representation = "&") + data object Tilde : CssTokenKind(representation = "~") + data object Equals : CssTokenKind(representation = "=") + data object Plus : CssTokenKind(representation = "+") + data object Minus : CssTokenKind(representation = "-") + data object Colon : CssTokenKind(representation = ":") + data object Semicolon : CssTokenKind(representation = ";") + data object Comma : CssTokenKind(representation = ",") + data object Greater : CssTokenKind(representation = ">") + data object Or : CssTokenKind(representation = "|") + data object Caret : CssTokenKind(representation = "^") + data object Dollar : CssTokenKind(representation = "$") + data object Slash : CssTokenKind(representation = "/") + data object Percent : CssTokenKind(representation = "%") + data object Bang : CssTokenKind(representation = "!") + data object Identifier : CssTokenKind(representation = "") + data object Hash : CssTokenKind(representation = "#") + + // Decimal or float + data object Number : CssTokenKind(representation = "") + data object HexDigit : CssTokenKind(representation = "") + data object StringLiteral : CssTokenKind(representation = "") + data object MultilineString : CssTokenKind(representation = "") + + // Missing end quote, mismatched quotes (missing start quote will yield one or more identifiers) + data object InvalidString : CssTokenKind(representation = "") + + // "URL(" string - note that space between URL and ( is not allowed + data object StartUrl : CssTokenKind(representation = "url(") + data object EndUrl : CssTokenKind(representation = ")") + + // Unquoted URL + data object UnquotedUrlString : CssTokenKind(representation = "") + data object OneOf : CssTokenKind(representation = "~=") + data object ContainsString : CssTokenKind(representation = "*=") + data object EndsWith : CssTokenKind(representation = "$") + data object BeginsWith : CssTokenKind(representation = "^=") + data object ListBeginsWith : CssTokenKind(representation = "|=") + data object DoubleColon : CssTokenKind(representation = "::") + data object DoublePipe : CssTokenKind(representation = "||") + data object OpenCurlyBrace : CssTokenKind(representation = "{") + data object CloseCurlyBrace : CssTokenKind(representation = "}") + data object OpenSquareBracket : CssTokenKind(representation = "[") + data object CloseSquareBracket : CssTokenKind(representation = "]") + data object OpenFunctionBrace : CssTokenKind(representation = "(") + data object CloseFunctionBrace : CssTokenKind(representation = ")") + data object HtmlComment : CssTokenKind(representation = "") + data object MultilineComment : CssTokenKind(representation = "") + data object SingleComment : CssTokenKind(representation = "") + data object Function : CssTokenKind(representation = "") + data object Units : CssTokenKind(representation = "") + data object Quote : CssTokenKind(representation = "'") + data object DoubleQuote : CssTokenKind(representation = "\"") + + // U+4E00-9FFF, U+30?? see https://www.w3.org/TR/css-fonts-3/#unicode-range-desc + data object UnicodeRange : CssTokenKind(representation = "u") +} diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssTokenTypes.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssTokenTypes.kt deleted file mode 100644 index d8742a78..00000000 --- a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/lexer/css/CssTokenTypes.kt +++ /dev/null @@ -1,69 +0,0 @@ -package dev.tonholo.s2c.lexer.css - -import dev.tonholo.s2c.lexer.TokenType - -sealed class CssTokenTypes( - override val representation: String, -) : TokenType { - data object EndOfFile : CssTokenTypes(representation = "") - data object WhiteSpace : CssTokenTypes(representation = " \n\t") - data object At : CssTokenTypes(representation = "@") - data object Dot : CssTokenTypes(representation = ".") - data object Asterisk : CssTokenTypes(representation = "*") - data object Ampersand : CssTokenTypes(representation = "&") - data object Tilde : CssTokenTypes(representation = "~") - data object Equals : CssTokenTypes(representation = "=") - data object Plus : CssTokenTypes(representation = "+") - data object Minus : CssTokenTypes(representation = "-") - data object Colon : CssTokenTypes(representation = ":") - data object Semicolon : CssTokenTypes(representation = ";") - data object Comma : CssTokenTypes(representation = ",") - data object Greater : CssTokenTypes(representation = ">") - data object Or : CssTokenTypes(representation = "|") - data object Caret : CssTokenTypes(representation = "^") - data object Dollar : CssTokenTypes(representation = "$") - data object Slash : CssTokenTypes(representation = "/") - data object Percent : CssTokenTypes(representation = "%") - data object Bang : CssTokenTypes(representation = "!") - data object Identifier : CssTokenTypes(representation = "") - data object Hash : CssTokenTypes(representation = "#") - - // Decimal or float - data object Number : CssTokenTypes(representation = "") - data object HexDigit : CssTokenTypes(representation = "") - data object StringLiteral : CssTokenTypes(representation = "") - data object MultilineString : CssTokenTypes(representation = "") - - // Missing end quote, mismatched quotes (missing start quote will yield one or more identifiers) - data object InvalidString : CssTokenTypes(representation = "") - - // "URL(" string - note that space between URL and ( is not allowed - data object StartUrl : CssTokenTypes(representation = "url(") - data object EndUrl : CssTokenTypes(representation = ")") - - // Unquoted URL - data object UnquotedUrlString : CssTokenTypes(representation = "") - data object OneOf : CssTokenTypes(representation = "~=") - data object ContainsString : CssTokenTypes(representation = "*=") - data object EndsWith : CssTokenTypes(representation = "$") - data object BeginsWith : CssTokenTypes(representation = "^=") - data object ListBeginsWith : CssTokenTypes(representation = "|=") - data object DoubleColon : CssTokenTypes(representation = "::") - data object DoublePipe : CssTokenTypes(representation = "||") - data object OpenCurlyBrace : CssTokenTypes(representation = "{") - data object CloseCurlyBrace : CssTokenTypes(representation = "}") - data object OpenSquareBracket : CssTokenTypes(representation = "[") - data object CloseSquareBracket : CssTokenTypes(representation = "]") - data object OpenFunctionBrace : CssTokenTypes(representation = "(") - data object CloseFunctionBrace : CssTokenTypes(representation = ")") - data object HtmlComment : CssTokenTypes(representation = "") - data object MultilineComment : CssTokenTypes(representation = "") - data object SingleComment : CssTokenTypes(representation = "") - data object Function : CssTokenTypes(representation = "") - data object Units : CssTokenTypes(representation = "") - data object Quote : CssTokenTypes(representation = "'") - data object DoubleQuote : CssTokenTypes(representation = "\"") - - // U+4E00-9FFF, U+30?? see https://www.w3.org/TR/css-fonts-3/#unicode-range-desc - data object UnicodeRange : CssTokenTypes(representation = "u") -} diff --git a/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/lexer/css/CssLexerTest.kt b/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/lexer/css/CssLexerTest.kt index 5c8fac99..0e71e96f 100644 --- a/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/lexer/css/CssLexerTest.kt +++ b/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/lexer/css/CssLexerTest.kt @@ -14,29 +14,29 @@ class CssLexerTest { |} """.trimMargin() - val expected = sequenceOf( - Token(type = CssTokenTypes.Dot, startOffset = 0, endOffset = 1), - Token(type = CssTokenTypes.Identifier, startOffset = 1, endOffset = 8), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 8, endOffset = 9), - Token(type = CssTokenTypes.OpenCurlyBrace, startOffset = 9, endOffset = 10), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 10, endOffset = 15), - Token(type = CssTokenTypes.Identifier, startOffset = 15, endOffset = 25), - Token(type = CssTokenTypes.Colon, startOffset = 25, endOffset = 26), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 26, endOffset = 27), - Token(type = CssTokenTypes.Hash, startOffset = 27, endOffset = 28), - Token(type = CssTokenTypes.HexDigit, startOffset = 28, endOffset = 31), - Token(type = CssTokenTypes.Semicolon, startOffset = 31, endOffset = 32), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 32, endOffset = 37), - Token(type = CssTokenTypes.Identifier, startOffset = 37, endOffset = 42), - Token(type = CssTokenTypes.Colon, startOffset = 42, endOffset = 43), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 43, endOffset = 44), - Token(type = CssTokenTypes.Hash, startOffset = 44, endOffset = 45), - Token(type = CssTokenTypes.HexDigit, startOffset = 45, endOffset = 48), - Token(type = CssTokenTypes.Semicolon, startOffset = 48, endOffset = 49), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 49, endOffset = 50), - Token(type = CssTokenTypes.CloseCurlyBrace, startOffset = 50, endOffset = 51), - Token(type = CssTokenTypes.EndOfFile, startOffset = 51, endOffset = 51), - ).toList() + val expected = listOf( + Token(kind = CssTokenKind.Dot, startOffset = 0, endOffset = 1), + Token(kind = CssTokenKind.Identifier, startOffset = 1, endOffset = 8), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 8, endOffset = 9), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 9, endOffset = 10), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 10, endOffset = 15), + Token(kind = CssTokenKind.Identifier, startOffset = 15, endOffset = 25), + Token(kind = CssTokenKind.Colon, startOffset = 25, endOffset = 26), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 26, endOffset = 27), + Token(kind = CssTokenKind.Hash, startOffset = 27, endOffset = 28), + Token(kind = CssTokenKind.HexDigit, startOffset = 28, endOffset = 31), + Token(kind = CssTokenKind.Semicolon, startOffset = 31, endOffset = 32), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 32, endOffset = 37), + Token(kind = CssTokenKind.Identifier, startOffset = 37, endOffset = 42), + Token(kind = CssTokenKind.Colon, startOffset = 42, endOffset = 43), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 43, endOffset = 44), + Token(kind = CssTokenKind.Hash, startOffset = 44, endOffset = 45), + Token(kind = CssTokenKind.HexDigit, startOffset = 45, endOffset = 48), + Token(kind = CssTokenKind.Semicolon, startOffset = 48, endOffset = 49), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 49, endOffset = 50), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 50, endOffset = 51), + Token(kind = CssTokenKind.EndOfFile, startOffset = 51, endOffset = 51), + ) val lexer = CssLexer() val actual = lexer.tokenize(input).toList() @@ -52,29 +52,29 @@ class CssLexerTest { |} """.trimMargin() - val expected = sequenceOf( - Token(type = CssTokenTypes.Hash, startOffset = 0, endOffset = 1), - Token(type = CssTokenTypes.Identifier, startOffset = 1, endOffset = 8), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 8, endOffset = 9), - Token(type = CssTokenTypes.OpenCurlyBrace, startOffset = 9, endOffset = 10), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 10, endOffset = 15), - Token(type = CssTokenTypes.Identifier, startOffset = 15, endOffset = 25), - Token(type = CssTokenTypes.Colon, startOffset = 25, endOffset = 26), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 26, endOffset = 27), - Token(type = CssTokenTypes.Hash, startOffset = 27, endOffset = 28), - Token(type = CssTokenTypes.HexDigit, startOffset = 28, endOffset = 31), - Token(type = CssTokenTypes.Semicolon, startOffset = 31, endOffset = 32), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 32, endOffset = 37), - Token(type = CssTokenTypes.Identifier, startOffset = 37, endOffset = 42), - Token(type = CssTokenTypes.Colon, startOffset = 42, endOffset = 43), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 43, endOffset = 44), - Token(type = CssTokenTypes.Hash, startOffset = 44, endOffset = 45), - Token(type = CssTokenTypes.HexDigit, startOffset = 45, endOffset = 48), - Token(type = CssTokenTypes.Semicolon, startOffset = 48, endOffset = 49), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 49, endOffset = 50), - Token(type = CssTokenTypes.CloseCurlyBrace, startOffset = 50, endOffset = 51), - Token(type = CssTokenTypes.EndOfFile, startOffset = 51, endOffset = 51), - ).toList() + val expected = listOf( + Token(kind = CssTokenKind.Hash, startOffset = 0, endOffset = 1), + Token(kind = CssTokenKind.Identifier, startOffset = 1, endOffset = 8), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 8, endOffset = 9), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 9, endOffset = 10), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 10, endOffset = 15), + Token(kind = CssTokenKind.Identifier, startOffset = 15, endOffset = 25), + Token(kind = CssTokenKind.Colon, startOffset = 25, endOffset = 26), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 26, endOffset = 27), + Token(kind = CssTokenKind.Hash, startOffset = 27, endOffset = 28), + Token(kind = CssTokenKind.HexDigit, startOffset = 28, endOffset = 31), + Token(kind = CssTokenKind.Semicolon, startOffset = 31, endOffset = 32), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 32, endOffset = 37), + Token(kind = CssTokenKind.Identifier, startOffset = 37, endOffset = 42), + Token(kind = CssTokenKind.Colon, startOffset = 42, endOffset = 43), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 43, endOffset = 44), + Token(kind = CssTokenKind.Hash, startOffset = 44, endOffset = 45), + Token(kind = CssTokenKind.HexDigit, startOffset = 45, endOffset = 48), + Token(kind = CssTokenKind.Semicolon, startOffset = 48, endOffset = 49), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 49, endOffset = 50), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 50, endOffset = 51), + Token(kind = CssTokenKind.EndOfFile, startOffset = 51, endOffset = 51), + ) val lexer = CssLexer() val actual = lexer.tokenize(input).toList() @@ -95,51 +95,51 @@ class CssLexerTest { |} """.trimMargin() - val expected = sequenceOf( - Token(type = CssTokenTypes.Dot, startOffset = 0, endOffset = 1), - Token(type = CssTokenTypes.Identifier, startOffset = 1, endOffset = 8), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 8, endOffset = 9), - Token(type = CssTokenTypes.OpenCurlyBrace, startOffset = 9, endOffset = 10), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 10, endOffset = 15), - Token(type = CssTokenTypes.Identifier, startOffset = 15, endOffset = 25), - Token(type = CssTokenTypes.Colon, startOffset = 25, endOffset = 26), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 26, endOffset = 27), - Token(type = CssTokenTypes.Hash, startOffset = 27, endOffset = 28), - Token(type = CssTokenTypes.HexDigit, startOffset = 28, endOffset = 31), - Token(type = CssTokenTypes.Semicolon, startOffset = 31, endOffset = 32), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 32, endOffset = 37), - Token(type = CssTokenTypes.Identifier, startOffset = 37, endOffset = 42), - Token(type = CssTokenTypes.Colon, startOffset = 42, endOffset = 43), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 43, endOffset = 44), - Token(type = CssTokenTypes.Hash, startOffset = 44, endOffset = 45), - Token(type = CssTokenTypes.HexDigit, startOffset = 45, endOffset = 48), - Token(type = CssTokenTypes.Semicolon, startOffset = 48, endOffset = 49), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 49, endOffset = 50), - Token(type = CssTokenTypes.CloseCurlyBrace, startOffset = 50, endOffset = 51), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 51, endOffset = 53), - - Token(type = CssTokenTypes.Hash, startOffset = 53, endOffset = 54), - Token(type = CssTokenTypes.Identifier, startOffset = 54, endOffset = 61), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 61, endOffset = 62), - Token(type = CssTokenTypes.OpenCurlyBrace, startOffset = 62, endOffset = 63), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 63, endOffset = 68), - Token(type = CssTokenTypes.Identifier, startOffset = 68, endOffset = 78), - Token(type = CssTokenTypes.Colon, startOffset = 78, endOffset = 79), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 79, endOffset = 80), - Token(type = CssTokenTypes.Hash, startOffset = 80, endOffset = 81), - Token(type = CssTokenTypes.HexDigit, startOffset = 81, endOffset = 84), - Token(type = CssTokenTypes.Semicolon, startOffset = 84, endOffset = 85), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 85, endOffset = 90), - Token(type = CssTokenTypes.Identifier, startOffset = 90, endOffset = 103), - Token(type = CssTokenTypes.Colon, startOffset = 103, endOffset = 104), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 104, endOffset = 105), - Token(type = CssTokenTypes.Number, startOffset = 105, endOffset = 106), - Token(type = CssTokenTypes.Identifier, startOffset = 106, endOffset = 108), - Token(type = CssTokenTypes.Semicolon, startOffset = 108, endOffset = 109), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 109, endOffset = 110), - Token(type = CssTokenTypes.CloseCurlyBrace, startOffset = 110, endOffset = 111), - Token(type = CssTokenTypes.EndOfFile, startOffset = 111, endOffset = 111), - ).toList() + val expected = listOf( + Token(kind = CssTokenKind.Dot, startOffset = 0, endOffset = 1), + Token(kind = CssTokenKind.Identifier, startOffset = 1, endOffset = 8), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 8, endOffset = 9), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 9, endOffset = 10), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 10, endOffset = 15), + Token(kind = CssTokenKind.Identifier, startOffset = 15, endOffset = 25), + Token(kind = CssTokenKind.Colon, startOffset = 25, endOffset = 26), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 26, endOffset = 27), + Token(kind = CssTokenKind.Hash, startOffset = 27, endOffset = 28), + Token(kind = CssTokenKind.HexDigit, startOffset = 28, endOffset = 31), + Token(kind = CssTokenKind.Semicolon, startOffset = 31, endOffset = 32), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 32, endOffset = 37), + Token(kind = CssTokenKind.Identifier, startOffset = 37, endOffset = 42), + Token(kind = CssTokenKind.Colon, startOffset = 42, endOffset = 43), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 43, endOffset = 44), + Token(kind = CssTokenKind.Hash, startOffset = 44, endOffset = 45), + Token(kind = CssTokenKind.HexDigit, startOffset = 45, endOffset = 48), + Token(kind = CssTokenKind.Semicolon, startOffset = 48, endOffset = 49), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 49, endOffset = 50), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 50, endOffset = 51), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 51, endOffset = 53), + + Token(kind = CssTokenKind.Hash, startOffset = 53, endOffset = 54), + Token(kind = CssTokenKind.Identifier, startOffset = 54, endOffset = 61), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 61, endOffset = 62), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 62, endOffset = 63), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 63, endOffset = 68), + Token(kind = CssTokenKind.Identifier, startOffset = 68, endOffset = 78), + Token(kind = CssTokenKind.Colon, startOffset = 78, endOffset = 79), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 79, endOffset = 80), + Token(kind = CssTokenKind.Hash, startOffset = 80, endOffset = 81), + Token(kind = CssTokenKind.HexDigit, startOffset = 81, endOffset = 84), + Token(kind = CssTokenKind.Semicolon, startOffset = 84, endOffset = 85), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 85, endOffset = 90), + Token(kind = CssTokenKind.Identifier, startOffset = 90, endOffset = 103), + Token(kind = CssTokenKind.Colon, startOffset = 103, endOffset = 104), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 104, endOffset = 105), + Token(kind = CssTokenKind.Number, startOffset = 105, endOffset = 106), + Token(kind = CssTokenKind.Identifier, startOffset = 106, endOffset = 108), + Token(kind = CssTokenKind.Semicolon, startOffset = 108, endOffset = 109), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 109, endOffset = 110), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 110, endOffset = 111), + Token(kind = CssTokenKind.EndOfFile, startOffset = 111, endOffset = 111), + ) val lexer = CssLexer() val actual = lexer.tokenize(input).toList() @@ -154,28 +154,57 @@ class CssLexerTest { |} """.trimMargin() - val expected = sequenceOf( - Token(type = CssTokenTypes.Hash, startOffset = 0, endOffset = 1), - Token(type = CssTokenTypes.Identifier, startOffset = 1, endOffset = 8), - Token(type = CssTokenTypes.Comma, startOffset = 8, endOffset = 9), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 9, endOffset = 10), - Token(type = CssTokenTypes.Dot, startOffset = 10, endOffset = 11), - Token(type = CssTokenTypes.Identifier, startOffset = 11, endOffset = 19), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 19, endOffset = 20), - Token(type = CssTokenTypes.OpenCurlyBrace, startOffset = 20, endOffset = 21), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 21, endOffset = 26), - Token(type = CssTokenTypes.Identifier, startOffset = 26, endOffset = 35), - Token(type = CssTokenTypes.Colon, startOffset = 35, endOffset = 36), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 36, endOffset = 37), - Token(type = CssTokenTypes.StartUrl, startOffset = 37, endOffset = 41), - Token(type = CssTokenTypes.Hash, startOffset = 41, endOffset = 42), - Token(type = CssTokenTypes.Identifier, startOffset = 42, endOffset = 54), - Token(type = CssTokenTypes.EndUrl, startOffset = 54, endOffset = 55), - Token(type = CssTokenTypes.Semicolon, startOffset = 55, endOffset = 56), - Token(type = CssTokenTypes.WhiteSpace, startOffset = 56, endOffset = 57), - Token(type = CssTokenTypes.CloseCurlyBrace, startOffset = 57, endOffset = 58), - Token(type = CssTokenTypes.EndOfFile, startOffset = 58, endOffset = 58), - ).toList() + val expected = listOf( + Token(kind = CssTokenKind.Hash, startOffset = 0, endOffset = 1), + Token(kind = CssTokenKind.Identifier, startOffset = 1, endOffset = 8), + Token(kind = CssTokenKind.Comma, startOffset = 8, endOffset = 9), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 9, endOffset = 10), + Token(kind = CssTokenKind.Dot, startOffset = 10, endOffset = 11), + Token(kind = CssTokenKind.Identifier, startOffset = 11, endOffset = 19), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 19, endOffset = 20), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 20, endOffset = 21), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 21, endOffset = 26), + Token(kind = CssTokenKind.Identifier, startOffset = 26, endOffset = 35), + Token(kind = CssTokenKind.Colon, startOffset = 35, endOffset = 36), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 36, endOffset = 37), + Token(kind = CssTokenKind.StartUrl, startOffset = 37, endOffset = 41), + Token(kind = CssTokenKind.Hash, startOffset = 41, endOffset = 42), + Token(kind = CssTokenKind.Identifier, startOffset = 42, endOffset = 54), + Token(kind = CssTokenKind.EndUrl, startOffset = 54, endOffset = 55), + Token(kind = CssTokenKind.Semicolon, startOffset = 55, endOffset = 56), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 56, endOffset = 57), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 57, endOffset = 58), + Token(kind = CssTokenKind.EndOfFile, startOffset = 58, endOffset = 58), + ) + + val lexer = CssLexer() + val actual = lexer.tokenize(input).toList() + assertEquals(expected, actual) + } + + @Test + fun `create tokens for rule with empty url`() { + val input = """ + |div { + | background-image: url(); + |} + """.trimMargin() + + val expected = listOf( + Token(kind = CssTokenKind.Identifier, startOffset = 0, endOffset = 3), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 3, endOffset = 4), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 4, endOffset = 5), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 5, endOffset = 10), + Token(kind = CssTokenKind.Identifier, startOffset = 10, endOffset = 26), + Token(kind = CssTokenKind.Colon, startOffset = 26, endOffset = 27), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 27, endOffset = 28), + Token(kind = CssTokenKind.StartUrl, startOffset = 28, endOffset = 32), + Token(kind = CssTokenKind.EndUrl, startOffset = 32, endOffset = 33), + Token(kind = CssTokenKind.Semicolon, startOffset = 33, endOffset = 34), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 34, endOffset = 35), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 35, endOffset = 36), + Token(kind = CssTokenKind.EndOfFile, startOffset = 36, endOffset = 36), + ) val lexer = CssLexer() val actual = lexer.tokenize(input).toList() From c0d28b4d4f61ec3393f8dd8cb233091335513d69 Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Wed, 27 Nov 2024 17:49:37 -0400 Subject: [PATCH 03/42] feat: introduce css AST parser This commit introduces a CSS AST (Abstract Syntax Tree) parser to analyze and extract style information from CSS code. The parser can handle various CSS rules, including class, ID, and tag selectors, as well as declarations with different property value types such as color, string literals, numbers, URLs, and more. This functionality is crucial for understanding and applying CSS styles within the project. --- .../dev/tonholo/s2c/parser/ast/AstParser.kt | 8 + .../dev/tonholo/s2c/parser/ast/Element.kt | 3 + .../s2c/parser/ast/css/CssAstParser.kt | 209 +++++++ .../tonholo/s2c/parser/ast/css/CssElements.kt | 40 ++ .../s2c/parser/ast/css/CssAstParserTest.kt | 508 ++++++++++++++++++ 5 files changed, 768 insertions(+) create mode 100644 svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/AstParser.kt create mode 100644 svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/Element.kt create mode 100644 svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParser.kt create mode 100644 svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssElements.kt create mode 100644 svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParserTest.kt diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/AstParser.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/AstParser.kt new file mode 100644 index 00000000..922b2cb1 --- /dev/null +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/AstParser.kt @@ -0,0 +1,8 @@ +package dev.tonholo.s2c.parser.ast + +import dev.tonholo.s2c.lexer.Token +import dev.tonholo.s2c.lexer.TokenKind + +internal interface AstParser { + fun parse(tokens: List>): TAstNode +} diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/Element.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/Element.kt new file mode 100644 index 00000000..f875fa25 --- /dev/null +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/Element.kt @@ -0,0 +1,3 @@ +package dev.tonholo.s2c.parser.ast + +internal interface Element diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParser.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParser.kt new file mode 100644 index 00000000..a8587404 --- /dev/null +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParser.kt @@ -0,0 +1,209 @@ +package dev.tonholo.s2c.parser.ast.css + +import dev.tonholo.s2c.lexer.Token +import dev.tonholo.s2c.lexer.css.CssTokenKind +import dev.tonholo.s2c.parser.ast.AstParser + +internal class CssAstParser( + private val content: String, +) : AstParser { + private var offset = 0 + private val tokens = mutableListOf>() + override fun parse(tokens: List>): CssRootNode { + this.tokens.addAll(tokens) + val rules = mutableListOf() + do { + val rule = parseNext(null) as? CssRule + if (rule != null) { + rules += rule + } + } while (rule != null) + return CssRootNode(rules) + } + + private fun next(): Token? = tokens + .takeIf { offset < it.size } + ?.get(offset++) + + private fun parseNext(sibling: CssElement?): CssElement? { + val starterToken = next() ?: return null + val element = when (starterToken.kind) { + // skip elements + CssTokenKind.WhiteSpace, + CssTokenKind.Comma, + CssTokenKind.OpenCurlyBrace, + CssTokenKind.Semicolon -> parseNext(sibling) + + CssTokenKind.CloseCurlyBrace, CssTokenKind.EndOfFile -> null + + // Potential selectors + CssTokenKind.Dot -> createClassSelector() + CssTokenKind.Hash -> createIdSelector() + CssTokenKind.Identifier -> createIdentifierElement(starterToken) + + else -> null + } + + return if (element is CssSelector && sibling == null) { + createCssRule(element) + } else { + element + } + } + + private fun createClassSelector(): CssSelector { + val token = requireNotNull(next()) { "Expected token but found null" } + check(token.kind is CssTokenKind.Identifier) { "Expected identifier but found ${token.kind}" } + return CssSelector( + type = CssSelectorType.Class, + value = content.substring(token.startOffset, token.endOffset), + ) + } + + private fun createIdSelector(): CssSelector { + val token = requireNotNull(next()) { "Expected token but found null" } + check(token.kind is CssTokenKind.Identifier) { "Expected identifier but found ${token.kind}" } + return CssSelector( + type = CssSelectorType.Id, + value = content.substring(token.startOffset, token.endOffset), + ) + } + + private fun createIdentifierElement(starterToken: Token): CssElement? { + val token = requireNotNull(next()) { "Expected token but found null" } + return when (token.kind) { + is CssTokenKind.WhiteSpace, + is CssTokenKind.Comma, + is CssTokenKind.OpenCurlyBrace -> { + CssSelector( + type = CssSelectorType.Tag, + value = content.substring(starterToken.startOffset, starterToken.endOffset), + ) + } + + is CssTokenKind.Colon -> { + val property = content.substring(starterToken.startOffset, starterToken.endOffset) + CssDeclaration( + property = property, + value = requireNotNull(next()?.parsePropertyValue()) { + "Incomplete property '$property' value" + }, + ) + } + + else -> null + } + } + + private fun createCssRule(element: CssSelector): CssElement { + val selectors = mutableListOf() + val declarations = mutableListOf() + // get siblings selectors + var next: CssElement? = element + while (next != null && next is CssSelector) { + selectors += next + next = parseNext(sibling = element) + if (next is CssDeclaration) { + break + } + } + + // get rule declarations + while (next != null && next is CssDeclaration) { + declarations += next + next = parseNext(sibling = null) + if (next !is CssDeclaration) { + break + } + } + + return CssRule( + selectors = selectors, + declarations = declarations, + ) + + } + + private fun Token.parsePropertyValue(): PropertyValue? { + var token = this + while (token.kind is CssTokenKind.WhiteSpace || token.kind is CssTokenKind.Colon) { + token = next() ?: return null + } + return when (token.kind) { + CssTokenKind.Hash -> { + val value = next() ?: return null + check(value.kind is CssTokenKind.HexDigit) { "Expected hex digit but found ${value.kind}" } + PropertyValue.Color(content.substring(token.startOffset, value.endOffset)) + } + + CssTokenKind.StringLiteral -> { + PropertyValue.StringLiteral(content.substring(token.startOffset, token.endOffset)) + } + + CssTokenKind.Number -> { + val unitsIdentifier = next() + val units = if (unitsIdentifier != null && unitsIdentifier.kind is CssTokenKind.Identifier) { + content.substring(unitsIdentifier.startOffset, unitsIdentifier.endOffset) + } else { + rewind() + null + } + + PropertyValue.Number( + value = content.substring(token.startOffset, token.endOffset), + units = units, + ) + } + + CssTokenKind.StartUrl -> { + var next = next() + check(next != null && next.kind !is CssTokenKind.EndUrl) { + rewind() + rewind() // return to start url. + buildErrorMessage( + message = "Incomplete URL value.", + backtrack = 3, + forward = 2, + ) + } + val startOffset = next.startOffset + var endOffset: Int + do { + next = next() + check(next != null) { + rewind() + buildErrorMessage("Incomplete URL value.") + } + endOffset = next.endOffset + } while (next?.kind != CssTokenKind.EndUrl) + PropertyValue.Url(value = content.substring(startOffset, endOffset - 1)) + } + + else -> null + } + } + + private fun rewind() { + offset-- + } + + private fun buildErrorMessage( + message: String, + backtrack: Int = 1, + forward: Int = 1, + ) = buildString { + appendLine(message) + val prev = tokens.getOrNull(offset - backtrack)?.startOffset ?: 0 + val next = tokens.getOrNull(offset + forward)?.endOffset ?: content.length + val current = tokens.getOrNull(offset) + appendLine("Start offset: ${current?.startOffset}") + appendLine("End offset: ${current?.endOffset}") + appendLine("Content:") + var indent = 4 + append(" ".repeat(indent)) + appendLine(content.substring(prev, next)) + indent += current?.startOffset?.minus(prev) ?: 0 + append(" ".repeat(indent)) + append("^".repeat(next.minus(current?.startOffset ?: 0))) + } +} diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssElements.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssElements.kt new file mode 100644 index 00000000..ae7a2031 --- /dev/null +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssElements.kt @@ -0,0 +1,40 @@ +package dev.tonholo.s2c.parser.ast.css + +import dev.tonholo.s2c.parser.ast.Element + +internal sealed interface CssElement : Element + +internal data class CssRootNode( + val rules: List +) : CssElement + +internal data class CssRule( + val selectors: List, + val declarations: List +) : CssElement + +internal data class CssSelector( + val type: CssSelectorType, + val value: String +) : CssElement + +internal sealed class CssSelectorType { + data object Id : CssSelectorType() + data object Class : CssSelectorType() + data object Tag : CssSelectorType() + data object Universal : CssSelectorType() +} + +internal data class CssDeclaration( + val property: String, + val value: PropertyValue, +) : CssElement + +internal sealed interface PropertyValue : Element { + data class Color(val value: String) : PropertyValue + data class StringLiteral(val value: String) : PropertyValue + data class Number(val value: String, val units: String?) : PropertyValue + data class Function(val name: String, val arguments: List) : PropertyValue + data class Url(val value: String) : PropertyValue + data class Literal(val value: String) : PropertyValue +} diff --git a/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParserTest.kt b/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParserTest.kt new file mode 100644 index 00000000..e09cbb0b --- /dev/null +++ b/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParserTest.kt @@ -0,0 +1,508 @@ +package dev.tonholo.s2c.parser.ast.css + +import dev.tonholo.s2c.lexer.Token +import dev.tonholo.s2c.lexer.css.CssTokenKind +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class CssAstParserTest { + @Test + fun `parse css class rule to CssRootNode`() { + val content = """ + |.my-rule { + | background: #f0f; + | color: #000; + |} + """.trimMargin() + val tokens = listOf( + Token(kind = CssTokenKind.Dot, startOffset = 0, endOffset = 1), + Token(kind = CssTokenKind.Identifier, startOffset = 1, endOffset = 8), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 8, endOffset = 9), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 9, endOffset = 10), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 10, endOffset = 15), + Token(kind = CssTokenKind.Identifier, startOffset = 15, endOffset = 25), + Token(kind = CssTokenKind.Colon, startOffset = 25, endOffset = 26), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 26, endOffset = 27), + Token(kind = CssTokenKind.Hash, startOffset = 27, endOffset = 28), + Token(kind = CssTokenKind.HexDigit, startOffset = 28, endOffset = 31), + Token(kind = CssTokenKind.Semicolon, startOffset = 31, endOffset = 32), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 32, endOffset = 37), + Token(kind = CssTokenKind.Identifier, startOffset = 37, endOffset = 42), + Token(kind = CssTokenKind.Colon, startOffset = 42, endOffset = 43), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 43, endOffset = 44), + Token(kind = CssTokenKind.Hash, startOffset = 44, endOffset = 45), + Token(kind = CssTokenKind.HexDigit, startOffset = 45, endOffset = 48), + Token(kind = CssTokenKind.Semicolon, startOffset = 48, endOffset = 49), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 49, endOffset = 50), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 50, endOffset = 51), + Token(kind = CssTokenKind.EndOfFile, startOffset = 51, endOffset = 51), + ) + val expected = CssRootNode( + rules = listOf( + CssRule( + selectors = listOf( + CssSelector( + type = CssSelectorType.Class, + value = "my-rule", + ), + ), + declarations = listOf( + CssDeclaration( + property = "background", + value = PropertyValue.Color("#f0f"), + ), + CssDeclaration( + property = "color", + value = PropertyValue.Color("#000"), + ), + ), + ) + ) + ) + + val astParser = CssAstParser(content) + val actual = astParser.parse(tokens) + assertEquals(expected, actual) + } + + @Test + fun `parse css id rule to CssRootNode`() { + val content = """ + |#my-rule { + | background: #f0f; + | color: #000; + |} + """.trimMargin() + + val tokens = listOf( + Token(kind = CssTokenKind.Hash, startOffset = 0, endOffset = 1), + Token(kind = CssTokenKind.Identifier, startOffset = 1, endOffset = 8), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 8, endOffset = 9), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 9, endOffset = 10), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 10, endOffset = 15), + Token(kind = CssTokenKind.Identifier, startOffset = 15, endOffset = 25), + Token(kind = CssTokenKind.Colon, startOffset = 25, endOffset = 26), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 26, endOffset = 27), + Token(kind = CssTokenKind.Hash, startOffset = 27, endOffset = 28), + Token(kind = CssTokenKind.HexDigit, startOffset = 28, endOffset = 31), + Token(kind = CssTokenKind.Semicolon, startOffset = 31, endOffset = 32), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 32, endOffset = 37), + Token(kind = CssTokenKind.Identifier, startOffset = 37, endOffset = 42), + Token(kind = CssTokenKind.Colon, startOffset = 42, endOffset = 43), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 43, endOffset = 44), + Token(kind = CssTokenKind.Hash, startOffset = 44, endOffset = 45), + Token(kind = CssTokenKind.HexDigit, startOffset = 45, endOffset = 48), + Token(kind = CssTokenKind.Semicolon, startOffset = 48, endOffset = 49), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 49, endOffset = 50), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 50, endOffset = 51), + Token(kind = CssTokenKind.EndOfFile, startOffset = 51, endOffset = 51), + ) + val expected = CssRootNode( + rules = listOf( + CssRule( + selectors = listOf( + CssSelector( + type = CssSelectorType.Id, + value = "my-rule", + ), + ), + declarations = listOf( + CssDeclaration( + property = "background", + value = PropertyValue.Color("#f0f"), + ), + CssDeclaration( + property = "color", + value = PropertyValue.Color("#000"), + ), + ), + ), + ), + ) + + val astParser = CssAstParser(content) + val actual = astParser.parse(tokens) + assertEquals(expected, actual) + } + + @Test + fun `parse css with multiple rules to CssRootNode`() { + val content = """ + |.my-rule { + | background: #f0f; + | color: #000; + |} + | + |#my-rule { + | background: #0ff; + | margin-bottom: 1px; + |} + """.trimMargin() + val tokens = listOf( + Token(kind = CssTokenKind.Dot, startOffset = 0, endOffset = 1), + Token(kind = CssTokenKind.Identifier, startOffset = 1, endOffset = 8), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 8, endOffset = 9), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 9, endOffset = 10), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 10, endOffset = 15), + Token(kind = CssTokenKind.Identifier, startOffset = 15, endOffset = 25), + Token(kind = CssTokenKind.Colon, startOffset = 25, endOffset = 26), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 26, endOffset = 27), + Token(kind = CssTokenKind.Hash, startOffset = 27, endOffset = 28), + Token(kind = CssTokenKind.HexDigit, startOffset = 28, endOffset = 31), + Token(kind = CssTokenKind.Semicolon, startOffset = 31, endOffset = 32), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 32, endOffset = 37), + Token(kind = CssTokenKind.Identifier, startOffset = 37, endOffset = 42), + Token(kind = CssTokenKind.Colon, startOffset = 42, endOffset = 43), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 43, endOffset = 44), + Token(kind = CssTokenKind.Hash, startOffset = 44, endOffset = 45), + Token(kind = CssTokenKind.HexDigit, startOffset = 45, endOffset = 48), + Token(kind = CssTokenKind.Semicolon, startOffset = 48, endOffset = 49), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 49, endOffset = 50), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 50, endOffset = 51), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 51, endOffset = 53), + + Token(kind = CssTokenKind.Hash, startOffset = 53, endOffset = 54), + Token(kind = CssTokenKind.Identifier, startOffset = 54, endOffset = 61), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 61, endOffset = 62), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 62, endOffset = 63), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 63, endOffset = 68), + Token(kind = CssTokenKind.Identifier, startOffset = 68, endOffset = 78), + Token(kind = CssTokenKind.Colon, startOffset = 78, endOffset = 79), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 79, endOffset = 80), + Token(kind = CssTokenKind.Hash, startOffset = 80, endOffset = 81), + Token(kind = CssTokenKind.HexDigit, startOffset = 81, endOffset = 84), + Token(kind = CssTokenKind.Semicolon, startOffset = 84, endOffset = 85), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 85, endOffset = 90), + Token(kind = CssTokenKind.Identifier, startOffset = 90, endOffset = 103), + Token(kind = CssTokenKind.Colon, startOffset = 103, endOffset = 104), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 104, endOffset = 105), + Token(kind = CssTokenKind.Number, startOffset = 105, endOffset = 106), + Token(kind = CssTokenKind.Identifier, startOffset = 106, endOffset = 108), + Token(kind = CssTokenKind.Semicolon, startOffset = 108, endOffset = 109), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 109, endOffset = 110), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 110, endOffset = 111), + Token(kind = CssTokenKind.EndOfFile, startOffset = 111, endOffset = 111), + ) + val expected = CssRootNode( + rules = listOf( + CssRule( + selectors = listOf( + CssSelector( + type = CssSelectorType.Class, + value = "my-rule", + ), + ), + declarations = listOf( + CssDeclaration( + property = "background", + value = PropertyValue.Color("#f0f"), + ), + CssDeclaration( + property = "color", + value = PropertyValue.Color("#000"), + ), + ), + ), + CssRule( + selectors = listOf( + CssSelector( + type = CssSelectorType.Id, + value = "my-rule", + ), + ), + declarations = listOf( + CssDeclaration( + property = "background", + value = PropertyValue.Color("#0ff"), + ), + CssDeclaration( + property = "margin-bottom", + value = PropertyValue.Number(value = "1", units = "px"), + ), + ) + ) + ), + ) + + val astParser = CssAstParser(content) + val actual = astParser.parse(tokens) + assertEquals(expected, actual) + } + + @Test + fun `parse css rule with number without unit`() { + val content = """ + |.my-rule { + | font-size: 16; + |} + """.trimMargin() + val tokens = listOf( + Token(CssTokenKind.Dot, startOffset = 0, endOffset = 1), + Token(CssTokenKind.Identifier, startOffset = 1, endOffset = 8), + Token(CssTokenKind.WhiteSpace, startOffset = 8, endOffset = 9), + Token(CssTokenKind.OpenCurlyBrace, startOffset = 9, endOffset = 10), + Token(CssTokenKind.WhiteSpace, startOffset = 10, endOffset = 15), + Token(CssTokenKind.Identifier, startOffset = 15, endOffset = 24), + Token(CssTokenKind.Colon, startOffset = 24, endOffset = 25), + Token(CssTokenKind.WhiteSpace, startOffset = 25, endOffset = 26), + Token(CssTokenKind.Number, startOffset = 26, endOffset = 28), + Token(CssTokenKind.Semicolon, startOffset = 28, endOffset = 29), + Token(CssTokenKind.WhiteSpace, startOffset = 29, endOffset = 30), + Token(CssTokenKind.CloseCurlyBrace, startOffset = 30, endOffset = 31), + Token(CssTokenKind.EndOfFile, startOffset = 31, endOffset = 31), + ) + val expected = CssRootNode( + rules = listOf( + CssRule( + selectors = listOf( + CssSelector( + type = CssSelectorType.Class, + value = "my-rule", + ), + ), + declarations = listOf( + CssDeclaration( + property = "font-size", + value = PropertyValue.Number(value = "16", units = null), + ), + ), + ), + ) + ) + + val astParser = CssAstParser(content) + val actual = astParser.parse(tokens) + assertEquals(expected, actual) + } + + @Test + fun `parse css rule with multiple selectors`() { + val content = """ + |#my-rule, .my-class { + | clip-path: url(#my-clip-path); + |} + """.trimMargin() + + val tokens = listOf( + Token(kind = CssTokenKind.Hash, startOffset = 0, endOffset = 1), + Token(kind = CssTokenKind.Identifier, startOffset = 1, endOffset = 8), + Token(kind = CssTokenKind.Comma, startOffset = 8, endOffset = 9), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 9, endOffset = 10), + Token(kind = CssTokenKind.Dot, startOffset = 10, endOffset = 11), + Token(kind = CssTokenKind.Identifier, startOffset = 11, endOffset = 19), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 19, endOffset = 20), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 20, endOffset = 21), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 21, endOffset = 26), + Token(kind = CssTokenKind.Identifier, startOffset = 26, endOffset = 35), + Token(kind = CssTokenKind.Colon, startOffset = 35, endOffset = 36), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 36, endOffset = 37), + Token(kind = CssTokenKind.StartUrl, startOffset = 37, endOffset = 41), + Token(kind = CssTokenKind.Hash, startOffset = 41, endOffset = 42), + Token(kind = CssTokenKind.Identifier, startOffset = 42, endOffset = 54), + Token(kind = CssTokenKind.EndUrl, startOffset = 54, endOffset = 55), + Token(kind = CssTokenKind.Semicolon, startOffset = 55, endOffset = 56), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 56, endOffset = 57), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 57, endOffset = 58), + Token(kind = CssTokenKind.EndOfFile, startOffset = 58, endOffset = 58), + ) + + val expected = CssRootNode( + rules = listOf( + CssRule( + selectors = listOf( + CssSelector( + type = CssSelectorType.Id, + value = "my-rule", + ), + CssSelector( + type = CssSelectorType.Class, + value = "my-class", + ), + ), + declarations = listOf( + CssDeclaration( + property = "clip-path", + value = PropertyValue.Url( + value = "#my-clip-path", + ), + ), + ), + ), + ), + ) + + val astParser = CssAstParser(content) + val actual = astParser.parse(tokens) + assertEquals(expected, actual) + } + + @Test + fun `parse css rule for tag`() { + val content = """ + |div { + | background: #f0f; + | color: #000; + |} + """.trimMargin() + val tokens = listOf( + Token(kind = CssTokenKind.Identifier, startOffset = 0, endOffset = 3), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 3, endOffset = 4), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 4, endOffset = 5), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 5, endOffset = 10), + Token(kind = CssTokenKind.Identifier, startOffset = 10, endOffset = 20), + Token(kind = CssTokenKind.Colon, startOffset = 20, endOffset = 21), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 21, endOffset = 22), + Token(kind = CssTokenKind.Hash, startOffset = 22, endOffset = 23), + Token(kind = CssTokenKind.HexDigit, startOffset = 23, endOffset = 26), + Token(kind = CssTokenKind.Semicolon, startOffset = 26, endOffset = 27), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 27, endOffset = 32), + Token(kind = CssTokenKind.Identifier, startOffset = 32, endOffset = 37), + Token(kind = CssTokenKind.Colon, startOffset = 37, endOffset = 38), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 38, endOffset = 39), + Token(kind = CssTokenKind.Hash, startOffset = 39, endOffset = 40), + Token(kind = CssTokenKind.HexDigit, startOffset = 40, endOffset = 43), + Token(kind = CssTokenKind.Semicolon, startOffset = 43, endOffset = 44), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 44, endOffset = 45), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 45, endOffset = 46), + Token(kind = CssTokenKind.EndOfFile, startOffset = 46, endOffset = 46), + ) + + val expected = CssRootNode( + rules = listOf( + CssRule( + selectors = listOf( + CssSelector( + type = CssSelectorType.Tag, + value = "div", + ), + ), + declarations = listOf( + CssDeclaration( + property = "background", + value = PropertyValue.Color("#f0f"), + ), + CssDeclaration( + property = "color", + value = PropertyValue.Color("#000"), + ), + ), + ), + ) + ) + + val astParser = CssAstParser(content) + val actual = astParser.parse(tokens) + assertEquals(expected, actual) + } + + @Test + fun `parse css rule for multiple selectors with different types`() { + val content = """ + |div, .content, #my-rule { + | background: #f0f; + | color: #000; + |} + """.trimMargin() + val tokens = listOf( + Token(kind = CssTokenKind.Identifier, startOffset = 0, endOffset = 3), + Token(kind = CssTokenKind.Comma, startOffset = 3, endOffset = 4), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 4, endOffset = 5), + Token(kind = CssTokenKind.Dot, startOffset = 5, endOffset = 6), + Token(kind = CssTokenKind.Identifier, startOffset = 6, endOffset = 13), + Token(kind = CssTokenKind.Comma, startOffset = 13, endOffset = 14), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 14, endOffset = 15), + Token(kind = CssTokenKind.Hash, startOffset = 15, endOffset = 16), + Token(kind = CssTokenKind.Identifier, startOffset = 16, endOffset = 23), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 23, endOffset = 24), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 24, endOffset = 25), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 25, endOffset = 30), + Token(kind = CssTokenKind.Identifier, startOffset = 30, endOffset = 40), + Token(kind = CssTokenKind.Colon, startOffset = 40, endOffset = 41), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 41, endOffset = 42), + Token(kind = CssTokenKind.Hash, startOffset = 42, endOffset = 43), + Token(kind = CssTokenKind.HexDigit, startOffset = 43, endOffset = 46), + Token(kind = CssTokenKind.Semicolon, startOffset = 46, endOffset = 47), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 47, endOffset = 52), + Token(kind = CssTokenKind.Identifier, startOffset = 52, endOffset = 57), + Token(kind = CssTokenKind.Colon, startOffset = 57, endOffset = 58), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 58, endOffset = 59), + Token(kind = CssTokenKind.Hash, startOffset = 59, endOffset = 60), + Token(kind = CssTokenKind.HexDigit, startOffset = 60, endOffset = 63), + Token(kind = CssTokenKind.Semicolon, startOffset = 63, endOffset = 64), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 64, endOffset = 65), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 65, endOffset = 66), + Token(kind = CssTokenKind.EndOfFile, startOffset = 66, endOffset = 66), + ) + + val expected = CssRootNode( + rules = listOf( + CssRule( + selectors = listOf( + CssSelector( + type = CssSelectorType.Tag, + value = "div", + ), + CssSelector( + type = CssSelectorType.Class, + value = "content", + ), + CssSelector( + type = CssSelectorType.Id, + value = "my-rule", + ), + ), + declarations = listOf( + CssDeclaration( + property = "background", + value = PropertyValue.Color("#f0f"), + ), + CssDeclaration( + property = "color", + value = PropertyValue.Color("#000"), + ), + ), + ), + ) + ) + + val astParser = CssAstParser(content) + val actual = astParser.parse(tokens) + assertEquals(expected, actual) + } + + @Test + fun `throw IllegalStateException when incomplete url property value`() { + val content = """ + |div { + | background-image: url(); + |} + """.trimMargin() + val tokens = listOf( + Token(kind = CssTokenKind.Identifier, startOffset = 0, endOffset = 3), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 3, endOffset = 4), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 4, endOffset = 5), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 5, endOffset = 10), + Token(kind = CssTokenKind.Identifier, startOffset = 10, endOffset = 26), + Token(kind = CssTokenKind.Colon, startOffset = 26, endOffset = 27), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 27, endOffset = 28), + Token(kind = CssTokenKind.StartUrl, startOffset = 28, endOffset = 32), + Token(kind = CssTokenKind.EndUrl, startOffset = 32, endOffset = 33), + Token(kind = CssTokenKind.Semicolon, startOffset = 33, endOffset = 34), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 34, endOffset = 35), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 35, endOffset = 36), + Token(kind = CssTokenKind.EndOfFile, startOffset = 36, endOffset = 36), + ) + + val astParser = CssAstParser(content) + val exception = assertFailsWith { + astParser.parse(tokens) + } + val message = exception.message + println(message) + assertNotNull(message) + assertContains(message, "Incomplete URL value.") + } +} From 46ed4a6c62d5f746e7b40fc76fd4459e17ab3999 Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Thu, 28 Nov 2024 13:14:42 -0400 Subject: [PATCH 04/42] chore: cli output enhancements --- gradle/libs.versions.toml | 3 +- svg-to-compose/build.gradle.kts | 3 +- svg-to-compose/src/nativeMain/kotlin/Main.kt | 43 ++++++++++++++------ 3 files changed, 34 insertions(+), 15 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b57f1f6c..276ae616 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,8 @@ kotlinx-coroutines = "1.9.0" dokka = "1.9.20" [libraries] -clikt = { group = "com.github.ajalt.clikt", name = "clikt", version.ref = "clikt" } +com-github-ajalt-clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } +com-github-ajalt-clikt-markdown = { module = "com.github.ajalt.clikt:clikt-markdown", version.ref = "clikt" } com-squareup-okio = { group = "com.squareup.okio", name = "okio", version.ref = "okio" } detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } com-fleeksoft-ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup"} diff --git a/svg-to-compose/build.gradle.kts b/svg-to-compose/build.gradle.kts index ec823404..15ddc505 100644 --- a/svg-to-compose/build.gradle.kts +++ b/svg-to-compose/build.gradle.kts @@ -19,7 +19,8 @@ kotlin { } nativeMain.dependencies { - implementation(libs.clikt) + implementation(libs.com.github.ajalt.clikt) + implementation(libs.com.github.ajalt.clikt.markdown) } } } diff --git a/svg-to-compose/src/nativeMain/kotlin/Main.kt b/svg-to-compose/src/nativeMain/kotlin/Main.kt index 92c5cc49..96ab3266 100644 --- a/svg-to-compose/src/nativeMain/kotlin/Main.kt +++ b/svg-to-compose/src/nativeMain/kotlin/Main.kt @@ -1,10 +1,15 @@ import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.PrintMessage +import com.github.ajalt.clikt.core.context +import com.github.ajalt.clikt.core.installMordantMarkdown import com.github.ajalt.clikt.core.main +import com.github.ajalt.clikt.output.MordantHelpFormatter +import com.github.ajalt.clikt.output.MordantMarkdownHelpFormatter import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.options.eagerOption import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.help import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.pair import com.github.ajalt.clikt.parameters.options.required @@ -21,15 +26,27 @@ import dev.tonholo.s2c.parser.ParserConfig import okio.FileSystem import platform.posix.exit +private const val MANUAL_LINE_BREAK = "\u0085" + fun main(args: Array) = Client() .main(args) class Client : CliktCommand(name = "s2c") { init { - eagerOption("-v", "--version", help = "Show this CLI version") { + eagerOption("-v", "--version", help = "Show this CLI version and exit") { throw PrintMessage("SVG to Compose version: ${BuildConfig.VERSION}") } + + context { + helpFormatter = { context -> + MordantMarkdownHelpFormatter( + context = context, + showDefaultValues = true, + requiredOptionMarker = "*", + ) + } + } } private val path by argument( @@ -62,14 +79,13 @@ class Client : CliktCommand(name = "s2c") { private val receiverType by option( names = arrayOf("-rt", "--receiver-type"), - help = """ + ).help { + """ |Adds a receiver type to the Icon definition. This will generate the Icon as a extension of the passed argument. - | - |E.g.: `s2c -o MyIcon.kt -rt Icons.Filled my-icon.svg` will creates the Compose Icon: - | - |`val Icons.Filled.MyIcon: ImageVector`. - """.trimMargin(), - ) + |${MANUAL_LINE_BREAK}E.g.: `s2c -o MyIcon.kt -rt Icons.Filled my-icon.svg` will creates the Compose Icon: + |$MANUAL_LINE_BREAK`val Icons.Filled.MyIcon: ImageVector`. + """.trimMargin() + } private val addToMaterial by option( names = arrayOf("--add-to-material"), @@ -93,14 +109,13 @@ class Client : CliktCommand(name = "s2c") { private val noPreview by option( names = arrayOf("-np", "--no-preview"), - help = "Removes the preview function from the file. It is very useful if you are generating the icons for " + - "KMP, since KMP doesn't support previews yet.", + help = "Removes the preview function from the file.", ).flag() private val isKmp by option( names = arrayOf("--kmp"), - help = "Ensures the output is compatible with KMP. Default: false", - ).flag() + help = "Ensures the output is compatible with KMP.", + ).boolean().default(false) private val makeInternal by option( names = arrayOf("--make-internal"), @@ -139,7 +154,7 @@ class Client : CliktCommand(name = "s2c") { help = """Replace the icon's name first value of this parameter with the second. |This is useful when you want to remove part of the icon's name from the output icon. | - |Example: + |For example, running the following command: |``` | s2c \ | -o ./my-app/src/my/pkg/icons \ @@ -147,6 +162,8 @@ class Client : CliktCommand(name = "s2c") { | --map-icon-name-from-to "_filled" "" | ./my-app/assets/svgs |``` + | + |An icon named `bright_sun_filled.svg` will create a `ImageVector` named `BrightSun`. """.trimMargin(), ).pair() From 3b4600cb3e85f383d9f65975396fbad05495b33c Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Thu, 28 Nov 2024 14:19:11 -0400 Subject: [PATCH 05/42] feat(css-parser): support identifier property values This commit adds support for identifier property values in the CSS parser. Identifier property values are represented by the `CssTokenKind.Identifier` token and are parsed into `PropertyValue.Identifier` objects. This change allows the parser to handle CSS properties with identifier values, such as `font-family`, `font-weight`, and `text-align`. --- .../s2c/parser/ast/css/CssAstParser.kt | 3 + .../tonholo/s2c/parser/ast/css/CssElements.kt | 2 +- .../dev/tonholo/s2c/lexer/css/CssLexerTest.kt | 71 +++++++--- .../s2c/parser/ast/css/CssAstParserTest.kt | 121 +++++++++++++++--- 4 files changed, 160 insertions(+), 37 deletions(-) diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParser.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParser.kt index a8587404..790ff566 100644 --- a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParser.kt +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParser.kt @@ -140,6 +140,9 @@ internal class CssAstParser( PropertyValue.StringLiteral(content.substring(token.startOffset, token.endOffset)) } + CssTokenKind.Identifier -> + PropertyValue.Identifier(content.substring(token.startOffset, token.endOffset)) + CssTokenKind.Number -> { val unitsIdentifier = next() val units = if (unitsIdentifier != null && unitsIdentifier.kind is CssTokenKind.Identifier) { diff --git a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssElements.kt b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssElements.kt index ae7a2031..3a82373b 100644 --- a/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssElements.kt +++ b/svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/parser/ast/css/CssElements.kt @@ -36,5 +36,5 @@ internal sealed interface PropertyValue : Element { data class Number(val value: String, val units: String?) : PropertyValue data class Function(val name: String, val arguments: List) : PropertyValue data class Url(val value: String) : PropertyValue - data class Literal(val value: String) : PropertyValue + data class Identifier(val value: String) : PropertyValue } diff --git a/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/lexer/css/CssLexerTest.kt b/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/lexer/css/CssLexerTest.kt index 0e71e96f..8bc4a3e0 100644 --- a/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/lexer/css/CssLexerTest.kt +++ b/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/lexer/css/CssLexerTest.kt @@ -38,9 +38,7 @@ class CssLexerTest { Token(kind = CssTokenKind.EndOfFile, startOffset = 51, endOffset = 51), ) - val lexer = CssLexer() - val actual = lexer.tokenize(input).toList() - assertEquals(expected, actual) + assert(input, expected) } @Test @@ -76,9 +74,7 @@ class CssLexerTest { Token(kind = CssTokenKind.EndOfFile, startOffset = 51, endOffset = 51), ) - val lexer = CssLexer() - val actual = lexer.tokenize(input).toList() - assertEquals(expected, actual) + assert(input, expected) } @Test @@ -140,10 +136,7 @@ class CssLexerTest { Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 110, endOffset = 111), Token(kind = CssTokenKind.EndOfFile, startOffset = 111, endOffset = 111), ) - - val lexer = CssLexer() - val actual = lexer.tokenize(input).toList() - assertEquals(expected, actual) + assert(input, expected) } @Test @@ -177,9 +170,7 @@ class CssLexerTest { Token(kind = CssTokenKind.EndOfFile, startOffset = 58, endOffset = 58), ) - val lexer = CssLexer() - val actual = lexer.tokenize(input).toList() - assertEquals(expected, actual) + assert(input, expected) } @Test @@ -206,8 +197,58 @@ class CssLexerTest { Token(kind = CssTokenKind.EndOfFile, startOffset = 36, endOffset = 36), ) + assert(input, expected) + } + + @Test + fun `create tokens for rule with element with multiple selectors`() { + val content = """ + |dev.element-class#element-id { + | display: none; + |} + """.trimMargin() + val tokens = listOf( + Token(kind = CssTokenKind.Identifier, startOffset = 0, endOffset = 3), + Token(kind = CssTokenKind.Dot, startOffset = 3, endOffset = 4), + Token(kind = CssTokenKind.Identifier, startOffset = 4, endOffset = 17), + Token(kind = CssTokenKind.Hash, startOffset = 17, endOffset = 18), + Token(kind = CssTokenKind.Identifier, startOffset = 18, endOffset = 28), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 28, endOffset = 29), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 29, endOffset = 30), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 30, endOffset = 34), + Token(kind = CssTokenKind.Identifier, startOffset = 34, endOffset = 41), + Token(kind = CssTokenKind.Colon, startOffset = 41, endOffset = 42), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 42, endOffset = 43), + Token(kind = CssTokenKind.Identifier, startOffset = 43, endOffset = 47), + Token(kind = CssTokenKind.Semicolon, startOffset = 47, endOffset = 48), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 48, endOffset = 49), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 49, endOffset = 50), + Token(kind = CssTokenKind.EndOfFile, startOffset = 50, endOffset = 50), + ) + assert(content, tokens) + } + + @Test + fun `create tokens for css without formatting`() { + val content = """ + |div{display:none} + """.trimMargin() + + val tokens = listOf( + Token(kind = CssTokenKind.Identifier, startOffset = 0, endOffset = 3), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 3, endOffset = 4), + Token(kind = CssTokenKind.Identifier, startOffset = 4, endOffset = 11), + Token(kind = CssTokenKind.Colon, startOffset = 11, endOffset = 12), + Token(kind = CssTokenKind.Identifier, startOffset = 12, endOffset = 16), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 16, endOffset = 17), + Token(kind = CssTokenKind.EndOfFile, startOffset = 17, endOffset = 17), + ) + assert(content, tokens) + } + + private fun assert(content: String, tokens: List>) { val lexer = CssLexer() - val actual = lexer.tokenize(input).toList() - assertEquals(expected, actual) + val actual = lexer.tokenize(content).toList() + assertEquals(tokens, actual) } } diff --git a/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParserTest.kt b/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParserTest.kt index e09cbb0b..a91c28c5 100644 --- a/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParserTest.kt +++ b/svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/parser/ast/css/CssAstParserTest.kt @@ -63,9 +63,7 @@ class CssAstParserTest { ) ) - val astParser = CssAstParser(content) - val actual = astParser.parse(tokens) - assertEquals(expected, actual) + assert(content, tokens, expected) } @Test @@ -123,9 +121,7 @@ class CssAstParserTest { ), ) - val astParser = CssAstParser(content) - val actual = astParser.parse(tokens) - assertEquals(expected, actual) + assert(content, tokens, expected) } @Test @@ -227,9 +223,7 @@ class CssAstParserTest { ), ) - val astParser = CssAstParser(content) - val actual = astParser.parse(tokens) - assertEquals(expected, actual) + assert(content, tokens, expected) } @Test @@ -273,9 +267,7 @@ class CssAstParserTest { ) ) - val astParser = CssAstParser(content) - val actual = astParser.parse(tokens) - assertEquals(expected, actual) + assert(content, tokens, expected) } @Test @@ -334,9 +326,7 @@ class CssAstParserTest { ), ) - val astParser = CssAstParser(content) - val actual = astParser.parse(tokens) - assertEquals(expected, actual) + assert(content, tokens, expected) } @Test @@ -393,9 +383,7 @@ class CssAstParserTest { ) ) - val astParser = CssAstParser(content) - val actual = astParser.parse(tokens) - assertEquals(expected, actual) + assert(content, tokens, expected) } @Test @@ -468,9 +456,90 @@ class CssAstParserTest { ) ) - val astParser = CssAstParser(content) - val actual = astParser.parse(tokens) - assertEquals(expected, actual) + assert(content, tokens, expected) + } + + @Test + fun `parse rules for css with element with multiple selectors`() { + val content = """ + |div.element-class#element-id { + | display: none; + |} + """.trimMargin() + val tokens = listOf( + Token(kind = CssTokenKind.Identifier, startOffset = 0, endOffset = 3), + Token(kind = CssTokenKind.Dot, startOffset = 3, endOffset = 4), + Token(kind = CssTokenKind.Identifier, startOffset = 4, endOffset = 17), + Token(kind = CssTokenKind.Hash, startOffset = 17, endOffset = 18), + Token(kind = CssTokenKind.Identifier, startOffset = 18, endOffset = 28), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 28, endOffset = 29), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 29, endOffset = 30), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 30, endOffset = 34), + Token(kind = CssTokenKind.Identifier, startOffset = 34, endOffset = 41), + Token(kind = CssTokenKind.Colon, startOffset = 41, endOffset = 42), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 42, endOffset = 43), + Token(kind = CssTokenKind.Identifier, startOffset = 43, endOffset = 47), + Token(kind = CssTokenKind.Semicolon, startOffset = 47, endOffset = 48), + Token(kind = CssTokenKind.WhiteSpace, startOffset = 48, endOffset = 49), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 49, endOffset = 50), + Token(kind = CssTokenKind.EndOfFile, startOffset = 50, endOffset = 50), + ) + val expected = CssRootNode( + rules = listOf( + CssRule( + selectors = listOf( + CssSelector( + type = CssSelectorType.Tag, + value = "div.element-class#element-id", + ), + ), + declarations = listOf( + CssDeclaration( + property = "display", + value = PropertyValue.Identifier("none"), + ), + ), + ), + ), + ) + + assert(content, tokens, expected) + } + + @Test + fun `parse rules for css without formatting`() { + val content = """ + |div{display:none} + """.trimMargin() + + val tokens = listOf( + Token(kind = CssTokenKind.Identifier, startOffset = 0, endOffset = 3), + Token(kind = CssTokenKind.OpenCurlyBrace, startOffset = 3, endOffset = 4), + Token(kind = CssTokenKind.Identifier, startOffset = 4, endOffset = 11), + Token(kind = CssTokenKind.Colon, startOffset = 11, endOffset = 12), + Token(kind = CssTokenKind.Identifier, startOffset = 12, endOffset = 16), + Token(kind = CssTokenKind.CloseCurlyBrace, startOffset = 16, endOffset = 17), + Token(kind = CssTokenKind.EndOfFile, startOffset = 17, endOffset = 17), + ) + val expected = CssRootNode( + rules = listOf( + CssRule( + selectors = listOf( + CssSelector( + type = CssSelectorType.Tag, + value = "div", + ), + ), + declarations = listOf( + CssDeclaration( + property = "display", + value = PropertyValue.Identifier("none"), + ), + ), + ), + ), + ) + assert(content, tokens, expected) } @Test @@ -505,4 +574,14 @@ class CssAstParserTest { assertNotNull(message) assertContains(message, "Incomplete URL value.") } + + private fun assert( + content: String, + tokens: List>, + expected: CssRootNode, + ) { + val astParser = CssAstParser(content) + val actual = astParser.parse(tokens) + assertEquals(expected, actual) + } } From 3bba49c59959a9fb4623d57c394b0e7054288efe Mon Sep 17 00:00:00 2001 From: Rafael Tonholo Date: Sat, 30 Nov 2024 09:52:11 -0400 Subject: [PATCH 06/42] refactor: extract SvgRootNode to its own file refactor: viewBox parsing logic, adding memoization and unit tests --- .run/svg-to-compose [allTests].run.xml | 2 +- buildSrc/build.gradle.kts | 1 + ...tonholo.s2c.conventions.testing.gradle.kts | 1 + gradle/libs.versions.toml | 2 + .../dev/tonholo/s2c/domain/svg/SvgNode.kt | 129 ++------- .../dev/tonholo/s2c/domain/svg/SvgRootNode.kt | 259 ++++++++++++++++++ .../tonholo/s2c/domain/svg/SvgViewBoxTest.kt | 144 ++++++++++ 7 files changed, 431 insertions(+), 107 deletions(-) create mode 100644 svg-to-compose/src/commonMain/kotlin/dev/tonholo/s2c/domain/svg/SvgRootNode.kt create mode 100644 svg-to-compose/src/commonTest/kotlin/dev/tonholo/s2c/domain/svg/SvgViewBoxTest.kt diff --git a/.run/svg-to-compose [allTests].run.xml b/.run/svg-to-compose [allTests].run.xml index ca04207c..61412e1d 100644 --- a/.run/svg-to-compose [allTests].run.xml +++ b/.run/svg-to-compose [allTests].run.xml @@ -2,7 +2,7 @@