diff --git a/config.json b/config.json index 564bbf10..75eeae68 100644 --- a/config.json +++ b/config.json @@ -1374,6 +1374,19 @@ "transforming" ] }, + { + "slug": "sgf-parsing", + "name": "SGF Parsing", + "uuid": "488cece4-7cda-48d3-b1c2-99e06d75411b", + "practices": [], + "prerequisites": [], + "difficulty": 7, + "topics": [ + "parsing", + "strings", + "recursion" + ] + }, { "slug": "poker", "name": "Poker", diff --git a/exercises/practice/sgf-parsing/.docs/instructions.md b/exercises/practice/sgf-parsing/.docs/instructions.md new file mode 100644 index 00000000..edc8d6b1 --- /dev/null +++ b/exercises/practice/sgf-parsing/.docs/instructions.md @@ -0,0 +1,83 @@ +# Instructions + +Parsing a Smart Game Format string. + +[SGF][sgf] is a standard format for storing board game files, in particular go. + +SGF is a fairly simple format. An SGF file usually contains a single +tree of nodes where each node is a property list. The property list +contains key value pairs, each key can only occur once but may have +multiple values. + +The exercise will have you parse an SGF string and return a tree structure of properties. + +An SGF file may look like this: + +```text +(;FF[4]C[root]SZ[19];B[aa];W[ab]) +``` + +This is a tree with three nodes: + +- The top level node has three properties: FF\[4\] (key = "FF", value + = "4"), C\[root\](key = "C", value = "root") and SZ\[19\] (key = + "SZ", value = "19"). (FF indicates the version of SGF, C is a + comment and SZ is the size of the board.) + - The top level node has a single child which has a single property: + B\[aa\]. (Black plays on the point encoded as "aa", which is the + 1-1 point). + - The B\[aa\] node has a single child which has a single property: + W\[ab\]. + +As you can imagine an SGF file contains a lot of nodes with a single +child, which is why there's a shorthand for it. + +SGF can encode variations of play. Go players do a lot of backtracking +in their reviews (let's try this, doesn't work, let's try that) and SGF +supports variations of play sequences. For example: + +```text +(;FF[4](;B[aa];W[ab])(;B[dd];W[ee])) +``` + +Here the root node has two variations. The first (which by convention +indicates what's actually played) is where black plays on 1-1. Black was +sent this file by his teacher who pointed out a more sensible play in +the second child of the root node: `B[dd]` (4-4 point, a very standard +opening to take the corner). + +A key can have multiple values associated with it. For example: + +```text +(;FF[4];AB[aa][ab][ba]) +``` + +Here `AB` (add black) is used to add three black stones to the board. + +All property values will be the [SGF Text type][sgf-text]. +You don't need to implement any other value type. +Although you can read the [full documentation of the Text type][sgf-text], a summary of the important points is below: + +- Newlines are removed if they come immediately after a `\`, otherwise they remain as newlines. +- All whitespace characters other than newline are converted to spaces. +- `\` is the escape character. + Any non-whitespace character after `\` is inserted as-is. + Any whitespace character after `\` follows the above rules. + Note that SGF does **not** have escape sequences for whitespace characters such as `\t` or `\n`. + +Be careful not to get confused between: + +- The string as it is represented in a string literal in the tests +- The string that is passed to the SGF parser + +Escape sequences in the string literals may have already been processed by the programming language's parser before they are passed to the SGF parser. + +There are a few more complexities to SGF (and parsing in general), which +you can mostly ignore. You should assume that the input is encoded in +UTF-8, the tests won't contain a charset property, so don't worry about +that. Furthermore you may assume that all newlines are unix style (`\n`, +no `\r` or `\r\n` will be in the tests) and that no optional whitespace +between properties, nodes, etc will be in the tests. + +[sgf]: https://en.wikipedia.org/wiki/Smart_Game_Format +[sgf-text]: https://www.red-bean.com/sgf/sgf4.html#text diff --git a/exercises/practice/sgf-parsing/.meta/Sources/SgfParsing/SGFParsingExample.swift b/exercises/practice/sgf-parsing/.meta/Sources/SgfParsing/SGFParsingExample.swift new file mode 100644 index 00000000..33c122d3 --- /dev/null +++ b/exercises/practice/sgf-parsing/.meta/Sources/SgfParsing/SGFParsingExample.swift @@ -0,0 +1,153 @@ +import Foundation + +/* + Backus–Naur form for Smart Game Format + + Collection = GameTree+ + GameTree = "(" Sequence GameTree* ")" + Sequence = Node+ + Node = ";" Property* + Property = PropIdent PropValue+ + PropIdent = UcLetter+ + PropValue = "[" CValueType "]" + CValueType = (ValueType | Compose) + ValueType = (None | Number | Real | Double | Color | SimpleText | Text | Point | Move | Stone) + ; Compose — это специальный тип для парных значений, например "dd:pp" + Compose = ValueType ":" ValueType +*/ + +enum SGFParsingError: Error { + case missingTree + case noNodes + case noDelimiter + case lowerCaseProperty + case parsingError +} + +struct SGFTree: Codable, Equatable { + var properties: [String: [String]] = [:] + var children: [SGFTree] = [] +} + +func parse(_ encoded: String) throws -> SGFTree { + let cursor = StringCursor(encoded) + return try parseGameTree(cursor) +} + +// MARK: - Parsing + +fileprivate func parseGameTree(_ cursor: StringCursor) throws -> SGFTree { + try expect("(", in: cursor, error: .missingTree) + var node = try parseSequence(cursor) + + cursor.skipWhitespace() + while cursor.current == "(" { + node.children.append(try parseGameTree(cursor)) + } + + try expect(")", in: cursor, error: .parsingError) + return node + +} + +fileprivate func parseSequence(_ cursor: StringCursor) throws -> SGFTree { + var node = try parseNode(cursor) + cursor.skipWhitespace() + if cursor.current == ";" { + node.children = [try parseSequence(cursor)] + } + return node +} + +fileprivate func parseNode(_ cursor: StringCursor) throws -> SGFTree { + try expect(";", in: cursor, error: .noNodes) + cursor.skipWhitespace() + + var properties = [String: [String]]() + while let current = cursor.current, current.isLetter { + let (key, values) = try parseProperty(cursor) + properties[key] = values + cursor.skipWhitespace() + } + return SGFTree(properties: properties, children: []) +} + +fileprivate func parseProperty(_ cursor: StringCursor) throws -> (key: String, values: [String]) { + cursor.skipWhitespace() + let key = try readKey(cursor) + guard !key.isEmpty else { throw SGFParsingError.parsingError } + guard cursor.current == "[" else { throw SGFParsingError.noDelimiter } + + var values = [String]() + while cursor.current == "[" { + values.append(try parseValue(cursor)) + } + + return (key, values) +} + +fileprivate func parseValue(_ cursor: StringCursor) throws -> String { + try expect("[", in: cursor, error: .noDelimiter) + var buffer = "" + + while let current = cursor.current { + switch current { + case "]": + cursor.advance() + return buffer + + case "\t": + buffer.append(" ") + + case "\\": + cursor.advance() + guard let next = cursor.current else { + throw SGFParsingError.parsingError + } + switch (next) { + case "\n": + break + + case "\t": + buffer.append(" ") + + default: + buffer.append(next) + } + + + default: + buffer.append(current) + } + cursor.advance() + } + throw SGFParsingError.parsingError +} + +fileprivate func readKey(_ cursor: StringCursor) throws -> String { + var key = "" + while let current = cursor.current, current != "[" { + guard current.isLetter else { + return key + } + guard current.isUppercase else { + throw SGFParsingError.lowerCaseProperty + } + key.append(current) + cursor.advance() + } + return key +} + +fileprivate func expect( + _ char: Character, + in cursor: StringCursor, + error: SGFParsingError +) throws { + cursor.skipWhitespace() + guard let current = cursor.current, current == char else { + throw error + } + cursor.advance() +} + diff --git a/exercises/practice/sgf-parsing/.meta/Sources/SgfParsing/StringCursor.swift b/exercises/practice/sgf-parsing/.meta/Sources/SgfParsing/StringCursor.swift new file mode 100644 index 00000000..99b77c1f --- /dev/null +++ b/exercises/practice/sgf-parsing/.meta/Sources/SgfParsing/StringCursor.swift @@ -0,0 +1,25 @@ +import Foundation + +final class StringCursor { + + var current: Character? { isEnd ? nil : text[index] } + + private let text: String + private var index: String.Index + private var isEnd: Bool { index >= text.endIndex } + + init(_ text: String) { + self.text = text + self.index = text.startIndex + } + + func advance() { + guard !isEnd else { return } + index = text.index(after: index) + } + + func skipWhitespace() { + while current?.isWhitespace ?? false { advance() } + } + +} diff --git a/exercises/practice/sgf-parsing/.meta/config.json b/exercises/practice/sgf-parsing/.meta/config.json new file mode 100644 index 00000000..89bc0145 --- /dev/null +++ b/exercises/practice/sgf-parsing/.meta/config.json @@ -0,0 +1,21 @@ +{ + "authors": [ + "Sencudra" + ], + "files": { + "solution": [ + "Sources/SgfParsing/SGFParsing.swift" + ], + "test": [ + "Tests/SgfParsingTests/SGFParsingTests.swift" + ], + "example": [ + ".meta/Sources/SgfParsing/SGFParsingExample.swift", + ".meta/Sources/SgfParsing/StringCursor.swift" + ], + "editor": [ + "Sources/SgfParsing/SGFTree.swift" + ] + }, + "blurb": "Parsing a Smart Game Format string." +} diff --git a/exercises/practice/sgf-parsing/.meta/template.swift b/exercises/practice/sgf-parsing/.meta/template.swift new file mode 100644 index 00000000..c1d46c65 --- /dev/null +++ b/exercises/practice/sgf-parsing/.meta/template.swift @@ -0,0 +1,33 @@ +import Testing +import Foundation +@testable import {{ exercise|camelCase }} + +let RUNALL = Bool(ProcessInfo.processInfo.environment["RUNALL", default: "false"]) ?? false + +@Suite struct {{ exercise|camelCase }}Tests { + {% for case in cases %} + {% if forloop.first -%} + @Test("{{ case.description }}") + {% else -%} + @Test("{{ case.description }}", .enabled(if: RUNALL)) + {% endif -%} + func test{{ case.description|camelCase }}() throws { + {%- if case.expected.error -%} + {%- if case.expected.error == "tree with no nodes" %} + #expect(throws: SGFParsingError.noNodes) + {%- elif case.expected.error == "tree missing" %} + #expect(throws: SGFParsingError.missingTree) + {%- elif case.expected.error == "properties without delimiter" %} + #expect(throws: SGFParsingError.noDelimiter) + {%- elif case.expected.error == "property must be in uppercase" %} + #expect(throws: SGFParsingError.lowerCaseProperty) + {%- endif -%} + { try {{ case.property }}("{{ case.input.encoded|inspect }}") } + {%- else -%} + let expectedTree = SGFTree(jsonString: "{{ case.expected|jsonString }}") + let actualTree = try parse("{{ case.input.encoded|inspect }}") + #expect(expectedTree == actualTree, "Expect trees to match") + {%- endif -%} + } + {% endfor -%} +} diff --git a/exercises/practice/sgf-parsing/.meta/tests.toml b/exercises/practice/sgf-parsing/.meta/tests.toml new file mode 100644 index 00000000..2a9d7d92 --- /dev/null +++ b/exercises/practice/sgf-parsing/.meta/tests.toml @@ -0,0 +1,84 @@ +# This is an auto-generated file. +# +# Regenerating this file via `configlet sync` will: +# - Recreate every `description` key/value pair +# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications +# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion) +# - Preserve any other key/value pair +# +# As user-added comments (using the # character) will be removed when this file +# is regenerated, comments can be added via a `comment` key. + +[2668d5dc-109f-4f71-b9d5-8d06b1d6f1cd] +description = "empty input" + +[84ded10a-94df-4a30-9457-b50ccbdca813] +description = "tree with no nodes" + +[0a6311b2-c615-4fa7-800e-1b1cbb68833d] +description = "node without tree" + +[8c419ed8-28c4-49f6-8f2d-433e706110ef] +description = "node without properties" + +[8209645f-32da-48fe-8e8f-b9b562c26b49] +description = "single node tree" + +[6c995856-b919-4c75-8fd6-c2c3c31b37dc] +description = "multiple properties" + +[a771f518-ec96-48ca-83c7-f8d39975645f] +description = "properties without delimiter" + +[6c02a24e-6323-4ed5-9962-187d19e36bc8] +description = "all lowercase property" + +[8772d2b1-3c57-405a-93ac-0703b671adc1] +description = "upper and lowercase property" + +[a759b652-240e-42ec-a6d2-3a08d834b9e2] +description = "two nodes" + +[cc7c02bc-6097-42c4-ab88-a07cb1533d00] +description = "two child trees" + +[724eeda6-00db-41b1-8aa9-4d5238ca0130] +description = "multiple property values" + +[28092c06-275f-4b9f-a6be-95663e69d4db] +description = "within property values, whitespace characters such as tab are converted to spaces" + +[deaecb9d-b6df-4658-aa92-dcd70f4d472a] +description = "within property values, newlines remain as newlines" + +[8e4c970e-42d7-440e-bfef-5d7a296868ef] +description = "escaped closing bracket within property value becomes just a closing bracket" + +[cf371fa8-ba4a-45ec-82fb-38668edcb15f] +description = "escaped backslash in property value becomes just a backslash" + +[dc13ca67-fac0-4b65-b3fe-c584d6a2c523] +description = "opening bracket within property value doesn't need to be escaped" + +[a780b97e-8dbb-474e-8f7e-4031902190e8] +description = "semicolon in property value doesn't need to be escaped" + +[0b57a79e-8d89-49e5-82b6-2eaaa6b88ed7] +description = "parentheses in property value don't need to be escaped" + +[c72a33af-9e04-4cc5-9890-1b92262813ac] +description = "escaped tab in property value is converted to space" + +[3a1023d2-7484-4498-8d73-3666bb386e81] +description = "escaped newline in property value is converted to nothing at all" + +[25abf1a4-5205-46f1-8c72-53273b94d009] +description = "escaped t and n in property value are just letters, not whitespace" + +[08e4b8ba-bb07-4431-a3d9-b1f4cdea6dab] +description = "mixing various kinds of whitespace and escaped characters in property value" +reimplements = "11c36323-93fc-495d-bb23-c88ee5844b8c" + +[11c36323-93fc-495d-bb23-c88ee5844b8c] +description = "escaped property" +include = false diff --git a/exercises/practice/sgf-parsing/Package.swift b/exercises/practice/sgf-parsing/Package.swift new file mode 100644 index 00000000..65f12b4e --- /dev/null +++ b/exercises/practice/sgf-parsing/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version:6.0 + +import PackageDescription + +let package = Package( + name: "SgfParsing", + products: [ + .library( + name: "SgfParsing", + targets: ["SgfParsing"]) + ], + dependencies: [], + targets: [ + .target( + name: "SgfParsing", + dependencies: []), + .testTarget( + name: "SgfParsingTests", + dependencies: ["SgfParsing"]), + ] +) diff --git a/exercises/practice/sgf-parsing/Sources/SgfParsing/SGFParsing.swift b/exercises/practice/sgf-parsing/Sources/SgfParsing/SGFParsing.swift new file mode 100644 index 00000000..28ce5019 --- /dev/null +++ b/exercises/practice/sgf-parsing/Sources/SgfParsing/SGFParsing.swift @@ -0,0 +1,5 @@ +import Foundation + +func parse(_ encoded: String) throws -> SGFTree { + // Write your code for the 'SGFParsing' exercise in this file. +} diff --git a/exercises/practice/sgf-parsing/Sources/SgfParsing/SGFTree.swift b/exercises/practice/sgf-parsing/Sources/SgfParsing/SGFTree.swift new file mode 100644 index 00000000..77600a5d --- /dev/null +++ b/exercises/practice/sgf-parsing/Sources/SgfParsing/SGFTree.swift @@ -0,0 +1,13 @@ +import Foundation + +enum SGFParsingError: Error { + case missingTree + case noNodes + case noDelimiter + case lowerCaseProperty +} + +struct SGFTree: Codable, Equatable { + var properties: [String: [String]] = [:] + var children: [SGFTree] = [] +} \ No newline at end of file diff --git a/exercises/practice/sgf-parsing/Tests/SgfParsingTests/SGFParsingTests.swift b/exercises/practice/sgf-parsing/Tests/SgfParsingTests/SGFParsingTests.swift new file mode 100644 index 00000000..70489ad4 --- /dev/null +++ b/exercises/practice/sgf-parsing/Tests/SgfParsingTests/SGFParsingTests.swift @@ -0,0 +1,187 @@ +import Foundation +import Testing + +@testable import SgfParsing + +let RUNALL = Bool(ProcessInfo.processInfo.environment["RUNALL", default: "false"]) ?? false + +@Suite struct SgfParsingTests { + + @Test("empty input") + func testEmptyInput() throws { + #expect(throws: SGFParsingError.missingTree) { try parse("") } + } + + @Test("tree with no nodes", .enabled(if: RUNALL)) + func testTreeWithNoNodes() throws { + #expect(throws: SGFParsingError.noNodes) { try parse("()") } + } + + @Test("node without tree", .enabled(if: RUNALL)) + func testNodeWithoutTree() throws { + #expect(throws: SGFParsingError.missingTree) { try parse(";") } + } + + @Test("node without properties", .enabled(if: RUNALL)) + func testNodeWithoutProperties() throws { + let expectedTree = SGFTree(jsonString: "{\"children\":[],\"properties\":{}}") + let actualTree = try parse("(;)") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test("single node tree", .enabled(if: RUNALL)) + func testSingleNodeTree() throws { + let expectedTree = SGFTree(jsonString: "{\"children\":[],\"properties\":{\"A\":[\"B\"]}}") + let actualTree = try parse("(;A[B])") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test("multiple properties", .enabled(if: RUNALL)) + func testMultipleProperties() throws { + let expectedTree = SGFTree( + jsonString: "{\"children\":[],\"properties\":{\"A\":[\"b\"],\"C\":[\"d\"]}}") + let actualTree = try parse("(;A[b]C[d])") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test("properties without delimiter", .enabled(if: RUNALL)) + func testPropertiesWithoutDelimiter() throws { + #expect(throws: SGFParsingError.noDelimiter) { try parse("(;A)") } + } + + @Test("all lowercase property", .enabled(if: RUNALL)) + func testAllLowercaseProperty() throws { + #expect(throws: SGFParsingError.lowerCaseProperty) { try parse("(;a[b])") } + } + + @Test("upper and lowercase property", .enabled(if: RUNALL)) + func testUpperAndLowercaseProperty() throws { + #expect(throws: SGFParsingError.lowerCaseProperty) { try parse("(;Aa[b])") } + } + + @Test("two nodes", .enabled(if: RUNALL)) + func testTwoNodes() throws { + let expectedTree = SGFTree( + jsonString: + "{\"children\":[{\"children\":[],\"properties\":{\"B\":[\"C\"]}}],\"properties\":{\"A\":[\"B\"]}}" + ) + let actualTree = try parse("(;A[B];B[C])") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test("two child trees", .enabled(if: RUNALL)) + func testTwoChildTrees() throws { + let expectedTree = SGFTree( + jsonString: + "{\"children\":[{\"children\":[],\"properties\":{\"B\":[\"C\"]}},{\"children\":[],\"properties\":{\"C\":[\"D\"]}}],\"properties\":{\"A\":[\"B\"]}}" + ) + let actualTree = try parse("(;A[B](;B[C])(;C[D]))") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test("multiple property values", .enabled(if: RUNALL)) + func testMultiplePropertyValues() throws { + let expectedTree = SGFTree( + jsonString: "{\"children\":[],\"properties\":{\"A\":[\"b\",\"c\",\"d\"]}}") + let actualTree = try parse("(;A[b][c][d])") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test( + "within property values, whitespace characters such as tab are converted to spaces", + .enabled(if: RUNALL)) + func testWithinPropertyValuesWhitespaceCharactersSuchAsTabAreConvertedToSpaces() throws { + let expectedTree = SGFTree( + jsonString: "{\"children\":[],\"properties\":{\"A\":[\"hello world\"]}}") + let actualTree = try parse("(;A[hello\t\tworld])") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test("within property values, newlines remain as newlines", .enabled(if: RUNALL)) + func testWithinPropertyValuesNewlinesRemainAsNewlines() throws { + let expectedTree = SGFTree( + jsonString: "{\"children\":[],\"properties\":{\"A\":[\"hello\\n\\nworld\"]}}") + let actualTree = try parse("(;A[hello\n\nworld])") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test( + "escaped closing bracket within property value becomes just a closing bracket", + .enabled(if: RUNALL)) + func testEscapedClosingBracketWithinPropertyValueBecomesJustAClosingBracket() throws { + let expectedTree = SGFTree(jsonString: "{\"children\":[],\"properties\":{\"A\":[\"]\"]}}") + let actualTree = try parse("(;A[\\]])") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test("escaped backslash in property value becomes just a backslash", .enabled(if: RUNALL)) + func testEscapedBackslashInPropertyValueBecomesJustABackslash() throws { + let expectedTree = SGFTree(jsonString: "{\"children\":[],\"properties\":{\"A\":[\"\\\\\"]}}") + let actualTree = try parse("(;A[\\\\])") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test("opening bracket within property value doesn't need to be escaped", .enabled(if: RUNALL)) + func testOpeningBracketWithinPropertyValueDoesntNeedToBeEscaped() throws { + let expectedTree = SGFTree( + jsonString: + "{\"children\":[{\"children\":[],\"properties\":{\"C\":[\"baz\"]}}],\"properties\":{\"A\":[\"x[y]z\",\"foo\"],\"B\":[\"bar\"]}}" + ) + let actualTree = try parse("(;A[x[y\\]z][foo]B[bar];C[baz])") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test("semicolon in property value doesn't need to be escaped", .enabled(if: RUNALL)) + func testSemicolonInPropertyValueDoesntNeedToBeEscaped() throws { + let expectedTree = SGFTree( + jsonString: + "{\"children\":[{\"children\":[],\"properties\":{\"C\":[\"baz\"]}}],\"properties\":{\"A\":[\"a;b\",\"foo\"],\"B\":[\"bar\"]}}" + ) + let actualTree = try parse("(;A[a;b][foo]B[bar];C[baz])") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test("parentheses in property value don't need to be escaped", .enabled(if: RUNALL)) + func testParenthesesInPropertyValueDontNeedToBeEscaped() throws { + let expectedTree = SGFTree( + jsonString: + "{\"children\":[{\"children\":[],\"properties\":{\"C\":[\"baz\"]}}],\"properties\":{\"A\":[\"x(y)z\",\"foo\"],\"B\":[\"bar\"]}}" + ) + let actualTree = try parse("(;A[x(y)z][foo]B[bar];C[baz])") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test("escaped tab in property value is converted to space", .enabled(if: RUNALL)) + func testEscapedTabInPropertyValueIsConvertedToSpace() throws { + let expectedTree = SGFTree( + jsonString: "{\"children\":[],\"properties\":{\"A\":[\"hello world\"]}}") + let actualTree = try parse("(;A[hello\\\tworld])") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test("escaped newline in property value is converted to nothing at all", .enabled(if: RUNALL)) + func testEscapedNewlineInPropertyValueIsConvertedToNothingAtAll() throws { + let expectedTree = SGFTree( + jsonString: "{\"children\":[],\"properties\":{\"A\":[\"helloworld\"]}}") + let actualTree = try parse("(;A[hello\\\nworld])") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test("escaped t and n in property value are just letters, not whitespace", .enabled(if: RUNALL)) + func testEscapedTAndNInPropertyValueAreJustLettersNotWhitespace() throws { + let expectedTree = SGFTree( + jsonString: "{\"children\":[],\"properties\":{\"A\":[\"t = t and n = n\"]}}") + let actualTree = try parse("(;A[\\t = t and \\n = n])") + #expect(expectedTree == actualTree, "Expect trees to match") + } + + @Test( + "mixing various kinds of whitespace and escaped characters in property value", + .enabled(if: RUNALL)) + func testMixingVariousKindsOfWhitespaceAndEscapedCharactersInPropertyValue() throws { + let expectedTree = SGFTree( + jsonString: "{\"children\":[],\"properties\":{\"A\":[\"]b\\ncd e\\\\ ]\"]}}") + let actualTree = try parse("(;A[\\]b\nc\\\nd\t\te\\\\ \\\n\\]])") + #expect(expectedTree == actualTree, "Expect trees to match") + } +} diff --git a/exercises/practice/sgf-parsing/Tests/SgfParsingTests/SGFTree+Initializer.swift b/exercises/practice/sgf-parsing/Tests/SgfParsingTests/SGFTree+Initializer.swift new file mode 100644 index 00000000..3be22a37 --- /dev/null +++ b/exercises/practice/sgf-parsing/Tests/SgfParsingTests/SGFTree+Initializer.swift @@ -0,0 +1,15 @@ +import Foundation + +@testable import SgfParsing + +extension SGFTree { + + init?(jsonString: String) { + guard let data = jsonString.data(using: .utf8) else { return nil } + + let decoder = JSONDecoder() + guard let tree = try? decoder.decode(SGFTree.self, from: data) else { return nil } + self = tree + } + +} \ No newline at end of file diff --git a/generator/Sources/Generator/generator-plugins.swift b/generator/Sources/Generator/generator-plugins.swift index 96d6a91e..c108ae36 100644 --- a/generator/Sources/Generator/generator-plugins.swift +++ b/generator/Sources/Generator/generator-plugins.swift @@ -2,6 +2,9 @@ import Foundation import Stencil class GeneratorPlugins { + + static let escapeChars = ["\t": "\\t", "\n": "\\n", "\r": "\\r", "\\": "\\\\", "\"": "\\\""] + func getPlugins() -> Environment { let ext = Extension() @@ -9,6 +12,13 @@ class GeneratorPlugins { return NSNull().isEqual(value) } + ext.registerFilter("jsonString") { (value: Any?) in + guard let value = value as? [String: Any] else { return nil } + let json = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) + guard let jsonString = String(data: json, encoding: .utf8) else { return nil } + return jsonString.map { Self.escapeChars[String($0)] ?? String($0) }.joined() + } + ext.registerFilter("camelCase") { (value: Any?) in if let inputString = value as? String { let charactersToRemove: [Character] = [ @@ -112,8 +122,7 @@ class GeneratorPlugins { ext.registerFilter("inspect") { (value: Any?) in if let inputString = value as? String { - let escapeChars = ["\t": "\\t", "\n": "\\n", "\r": "\\r", "\\": "\\\\", "\"": "\\\""] - return inputString.map { escapeChars[String($0)] ?? String($0) }.joined() + return inputString.map { Self.escapeChars[String($0)] ?? String($0) }.joined() } return nil }