Skip to content

Commit 1d8c868

Browse files
authored
[New Exercise] Add sgf-parsing exercise + New jsonString plugin to generator (exercism#878)
* Add tests and modify generator. * Solve exercise
1 parent ad358bb commit 1d8c868

File tree

13 files changed

+664
-2
lines changed

13 files changed

+664
-2
lines changed

config.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1374,6 +1374,19 @@
13741374
"transforming"
13751375
]
13761376
},
1377+
{
1378+
"slug": "sgf-parsing",
1379+
"name": "SGF Parsing",
1380+
"uuid": "488cece4-7cda-48d3-b1c2-99e06d75411b",
1381+
"practices": [],
1382+
"prerequisites": [],
1383+
"difficulty": 7,
1384+
"topics": [
1385+
"parsing",
1386+
"strings",
1387+
"recursion"
1388+
]
1389+
},
13771390
{
13781391
"slug": "poker",
13791392
"name": "Poker",
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Instructions
2+
3+
Parsing a Smart Game Format string.
4+
5+
[SGF][sgf] is a standard format for storing board game files, in particular go.
6+
7+
SGF is a fairly simple format. An SGF file usually contains a single
8+
tree of nodes where each node is a property list. The property list
9+
contains key value pairs, each key can only occur once but may have
10+
multiple values.
11+
12+
The exercise will have you parse an SGF string and return a tree structure of properties.
13+
14+
An SGF file may look like this:
15+
16+
```text
17+
(;FF[4]C[root]SZ[19];B[aa];W[ab])
18+
```
19+
20+
This is a tree with three nodes:
21+
22+
- The top level node has three properties: FF\[4\] (key = "FF", value
23+
= "4"), C\[root\](key = "C", value = "root") and SZ\[19\] (key =
24+
"SZ", value = "19"). (FF indicates the version of SGF, C is a
25+
comment and SZ is the size of the board.)
26+
- The top level node has a single child which has a single property:
27+
B\[aa\]. (Black plays on the point encoded as "aa", which is the
28+
1-1 point).
29+
- The B\[aa\] node has a single child which has a single property:
30+
W\[ab\].
31+
32+
As you can imagine an SGF file contains a lot of nodes with a single
33+
child, which is why there's a shorthand for it.
34+
35+
SGF can encode variations of play. Go players do a lot of backtracking
36+
in their reviews (let's try this, doesn't work, let's try that) and SGF
37+
supports variations of play sequences. For example:
38+
39+
```text
40+
(;FF[4](;B[aa];W[ab])(;B[dd];W[ee]))
41+
```
42+
43+
Here the root node has two variations. The first (which by convention
44+
indicates what's actually played) is where black plays on 1-1. Black was
45+
sent this file by his teacher who pointed out a more sensible play in
46+
the second child of the root node: `B[dd]` (4-4 point, a very standard
47+
opening to take the corner).
48+
49+
A key can have multiple values associated with it. For example:
50+
51+
```text
52+
(;FF[4];AB[aa][ab][ba])
53+
```
54+
55+
Here `AB` (add black) is used to add three black stones to the board.
56+
57+
All property values will be the [SGF Text type][sgf-text].
58+
You don't need to implement any other value type.
59+
Although you can read the [full documentation of the Text type][sgf-text], a summary of the important points is below:
60+
61+
- Newlines are removed if they come immediately after a `\`, otherwise they remain as newlines.
62+
- All whitespace characters other than newline are converted to spaces.
63+
- `\` is the escape character.
64+
Any non-whitespace character after `\` is inserted as-is.
65+
Any whitespace character after `\` follows the above rules.
66+
Note that SGF does **not** have escape sequences for whitespace characters such as `\t` or `\n`.
67+
68+
Be careful not to get confused between:
69+
70+
- The string as it is represented in a string literal in the tests
71+
- The string that is passed to the SGF parser
72+
73+
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.
74+
75+
There are a few more complexities to SGF (and parsing in general), which
76+
you can mostly ignore. You should assume that the input is encoded in
77+
UTF-8, the tests won't contain a charset property, so don't worry about
78+
that. Furthermore you may assume that all newlines are unix style (`\n`,
79+
no `\r` or `\r\n` will be in the tests) and that no optional whitespace
80+
between properties, nodes, etc will be in the tests.
81+
82+
[sgf]: https://en.wikipedia.org/wiki/Smart_Game_Format
83+
[sgf-text]: https://www.red-bean.com/sgf/sgf4.html#text
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import Foundation
2+
3+
/*
4+
Backus–Naur form for Smart Game Format
5+
6+
Collection = GameTree+
7+
GameTree = "(" Sequence GameTree* ")"
8+
Sequence = Node+
9+
Node = ";" Property*
10+
Property = PropIdent PropValue+
11+
PropIdent = UcLetter+
12+
PropValue = "[" CValueType "]"
13+
CValueType = (ValueType | Compose)
14+
ValueType = (None | Number | Real | Double | Color | SimpleText | Text | Point | Move | Stone)
15+
; Compose — это специальный тип для парных значений, например "dd:pp"
16+
Compose = ValueType ":" ValueType
17+
*/
18+
19+
enum SGFParsingError: Error {
20+
case missingTree
21+
case noNodes
22+
case noDelimiter
23+
case lowerCaseProperty
24+
case parsingError
25+
}
26+
27+
struct SGFTree: Codable, Equatable {
28+
var properties: [String: [String]] = [:]
29+
var children: [SGFTree] = []
30+
}
31+
32+
func parse(_ encoded: String) throws -> SGFTree {
33+
let cursor = StringCursor(encoded)
34+
return try parseGameTree(cursor)
35+
}
36+
37+
// MARK: - Parsing
38+
39+
fileprivate func parseGameTree(_ cursor: StringCursor) throws -> SGFTree {
40+
try expect("(", in: cursor, error: .missingTree)
41+
var node = try parseSequence(cursor)
42+
43+
cursor.skipWhitespace()
44+
while cursor.current == "(" {
45+
node.children.append(try parseGameTree(cursor))
46+
}
47+
48+
try expect(")", in: cursor, error: .parsingError)
49+
return node
50+
51+
}
52+
53+
fileprivate func parseSequence(_ cursor: StringCursor) throws -> SGFTree {
54+
var node = try parseNode(cursor)
55+
cursor.skipWhitespace()
56+
if cursor.current == ";" {
57+
node.children = [try parseSequence(cursor)]
58+
}
59+
return node
60+
}
61+
62+
fileprivate func parseNode(_ cursor: StringCursor) throws -> SGFTree {
63+
try expect(";", in: cursor, error: .noNodes)
64+
cursor.skipWhitespace()
65+
66+
var properties = [String: [String]]()
67+
while let current = cursor.current, current.isLetter {
68+
let (key, values) = try parseProperty(cursor)
69+
properties[key] = values
70+
cursor.skipWhitespace()
71+
}
72+
return SGFTree(properties: properties, children: [])
73+
}
74+
75+
fileprivate func parseProperty(_ cursor: StringCursor) throws -> (key: String, values: [String]) {
76+
cursor.skipWhitespace()
77+
let key = try readKey(cursor)
78+
guard !key.isEmpty else { throw SGFParsingError.parsingError }
79+
guard cursor.current == "[" else { throw SGFParsingError.noDelimiter }
80+
81+
var values = [String]()
82+
while cursor.current == "[" {
83+
values.append(try parseValue(cursor))
84+
}
85+
86+
return (key, values)
87+
}
88+
89+
fileprivate func parseValue(_ cursor: StringCursor) throws -> String {
90+
try expect("[", in: cursor, error: .noDelimiter)
91+
var buffer = ""
92+
93+
while let current = cursor.current {
94+
switch current {
95+
case "]":
96+
cursor.advance()
97+
return buffer
98+
99+
case "\t":
100+
buffer.append(" ")
101+
102+
case "\\":
103+
cursor.advance()
104+
guard let next = cursor.current else {
105+
throw SGFParsingError.parsingError
106+
}
107+
switch (next) {
108+
case "\n":
109+
break
110+
111+
case "\t":
112+
buffer.append(" ")
113+
114+
default:
115+
buffer.append(next)
116+
}
117+
118+
119+
default:
120+
buffer.append(current)
121+
}
122+
cursor.advance()
123+
}
124+
throw SGFParsingError.parsingError
125+
}
126+
127+
fileprivate func readKey(_ cursor: StringCursor) throws -> String {
128+
var key = ""
129+
while let current = cursor.current, current != "[" {
130+
guard current.isLetter else {
131+
return key
132+
}
133+
guard current.isUppercase else {
134+
throw SGFParsingError.lowerCaseProperty
135+
}
136+
key.append(current)
137+
cursor.advance()
138+
}
139+
return key
140+
}
141+
142+
fileprivate func expect(
143+
_ char: Character,
144+
in cursor: StringCursor,
145+
error: SGFParsingError
146+
) throws {
147+
cursor.skipWhitespace()
148+
guard let current = cursor.current, current == char else {
149+
throw error
150+
}
151+
cursor.advance()
152+
}
153+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import Foundation
2+
3+
final class StringCursor {
4+
5+
var current: Character? { isEnd ? nil : text[index] }
6+
7+
private let text: String
8+
private var index: String.Index
9+
private var isEnd: Bool { index >= text.endIndex }
10+
11+
init(_ text: String) {
12+
self.text = text
13+
self.index = text.startIndex
14+
}
15+
16+
func advance() {
17+
guard !isEnd else { return }
18+
index = text.index(after: index)
19+
}
20+
21+
func skipWhitespace() {
22+
while current?.isWhitespace ?? false { advance() }
23+
}
24+
25+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"authors": [
3+
"Sencudra"
4+
],
5+
"files": {
6+
"solution": [
7+
"Sources/SgfParsing/SGFParsing.swift"
8+
],
9+
"test": [
10+
"Tests/SgfParsingTests/SGFParsingTests.swift"
11+
],
12+
"example": [
13+
".meta/Sources/SgfParsing/SGFParsingExample.swift",
14+
".meta/Sources/SgfParsing/StringCursor.swift"
15+
],
16+
"editor": [
17+
"Sources/SgfParsing/SGFTree.swift"
18+
]
19+
},
20+
"blurb": "Parsing a Smart Game Format string."
21+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import Testing
2+
import Foundation
3+
@testable import {{ exercise|camelCase }}
4+
5+
let RUNALL = Bool(ProcessInfo.processInfo.environment["RUNALL", default: "false"]) ?? false
6+
7+
@Suite struct {{ exercise|camelCase }}Tests {
8+
{% for case in cases %}
9+
{% if forloop.first -%}
10+
@Test("{{ case.description }}")
11+
{% else -%}
12+
@Test("{{ case.description }}", .enabled(if: RUNALL))
13+
{% endif -%}
14+
func test{{ case.description|camelCase }}() throws {
15+
{%- if case.expected.error -%}
16+
{%- if case.expected.error == "tree with no nodes" %}
17+
#expect(throws: SGFParsingError.noNodes)
18+
{%- elif case.expected.error == "tree missing" %}
19+
#expect(throws: SGFParsingError.missingTree)
20+
{%- elif case.expected.error == "properties without delimiter" %}
21+
#expect(throws: SGFParsingError.noDelimiter)
22+
{%- elif case.expected.error == "property must be in uppercase" %}
23+
#expect(throws: SGFParsingError.lowerCaseProperty)
24+
{%- endif -%}
25+
{ try {{ case.property }}("{{ case.input.encoded|inspect }}") }
26+
{%- else -%}
27+
let expectedTree = SGFTree(jsonString: "{{ case.expected|jsonString }}")
28+
let actualTree = try parse("{{ case.input.encoded|inspect }}")
29+
#expect(expectedTree == actualTree, "Expect trees to match")
30+
{%- endif -%}
31+
}
32+
{% endfor -%}
33+
}

0 commit comments

Comments
 (0)