diff --git a/config.json b/config.json index 7fefd1ea2..aa6db50ac 100644 --- a/config.json +++ b/config.json @@ -1879,6 +1879,20 @@ "conditionals-if" ], "difficulty": 8 + }, + { + "slug": "sgf-parsing", + "name": "SGF Parsing", + "uuid": "0d6325d1-c0a3-456e-9a92-cea0559e82ed", + "practices": [], + "prerequisites": [ + "strings", + "chars", + "conditionals-if", + "lists", + "for-loops" + ], + "difficulty": 7 } ], "foregone": [] diff --git a/exercises/practice/sgf-parsing/.docs/instructions.md b/exercises/practice/sgf-parsing/.docs/instructions.md new file mode 100644 index 000000000..cffc012fd --- /dev/null +++ b/exercises/practice/sgf-parsing/.docs/instructions.md @@ -0,0 +1,52 @@ +# Instructions + +Parsing a Smart Game Format string. + +[SGF](https://en.wikipedia.org/wiki/Smart_Game_Format) 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. + +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. + +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. + +The exercise will have you parse an SGF string and return a tree structure of properties. +You do not need to encode knowledge about the data types of properties, just use the rules for the [text](http://www.red-bean.com/sgf/sgf4.html#text) type everywhere. diff --git a/exercises/practice/sgf-parsing/.meta/config.json b/exercises/practice/sgf-parsing/.meta/config.json new file mode 100644 index 000000000..d48991d02 --- /dev/null +++ b/exercises/practice/sgf-parsing/.meta/config.json @@ -0,0 +1,15 @@ +{ + "authors": ["tlphat"], + "files": { + "solution": [ + "src/main/java/SgfParsing.java" + ], + "test": [ + "src/test/java/SgfParsingTest.java" + ], + "example": [ + ".meta/src/reference/java/SgfParsing.java" + ] + }, + "blurb": "Parsing a Smart Game Format string." +} diff --git a/exercises/practice/sgf-parsing/.meta/src/reference/java/SgfNode.java b/exercises/practice/sgf-parsing/.meta/src/reference/java/SgfNode.java new file mode 100644 index 000000000..85fc19dbb --- /dev/null +++ b/exercises/practice/sgf-parsing/.meta/src/reference/java/SgfNode.java @@ -0,0 +1,60 @@ +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class SgfNode { + + private Map> properties; + private List children; + + public SgfNode() { + properties = new HashMap<>(); + children = new ArrayList<>(); + } + + public SgfNode(Map> properties) { + this.properties = properties; + children = new ArrayList<>(); + } + + public SgfNode(Map> properties, List children) { + this.properties = properties; + this.children = children; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SgfNode sgfNode = (SgfNode) o; + return properties.equals(sgfNode.properties) && children.equals(sgfNode.children); + } + + @Override + public int hashCode() { + return Objects.hash(properties, children); + } + + @Override + public String toString() { + return "SgfNode{" + + "properties=" + properties + + ", children=" + children + + '}'; + } + + public void appendChild(SgfNode node) { + children.add(node); + } + + public void setProperties(Map> properties) { + this.properties = properties; + } + +} diff --git a/exercises/practice/sgf-parsing/.meta/src/reference/java/SgfParsing.java b/exercises/practice/sgf-parsing/.meta/src/reference/java/SgfParsing.java new file mode 100644 index 000000000..304019c2e --- /dev/null +++ b/exercises/practice/sgf-parsing/.meta/src/reference/java/SgfParsing.java @@ -0,0 +1,123 @@ +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class SgfParsing { + + public SgfNode parse(String input) throws SgfParsingException { + checkIfTreeHasAnyNode(input); + checkIfTreeExists(input); + SgfNode tree = new SgfNode(); + parseFromIndex(input, 2, tree); + return tree; + } + + private void checkIfTreeHasAnyNode(String input) throws SgfParsingException { + if ("()".equals(input)) { + throw new SgfParsingException("tree with no nodes"); + } + } + + private void checkIfTreeExists(String input) throws SgfParsingException { + if (input.isEmpty() || !input.startsWith("(;")) { + throw new SgfParsingException("tree missing"); + } + } + + private int parseFromIndex(String input, int index, SgfNode root) throws SgfParsingException { + StringBuilder buffer = new StringBuilder(); + Map> properties = new HashMap<>(); + String key = null; + while (index < input.length()) { + switch (input.charAt(index)) { + case '(': + index = addNewChild(input, index, root); + break; + case ')': + break; + case '[': + key = loadKeyFromBuffer(buffer, properties); + break; + case ']': + properties.get(key).add(popStringFromBuffer(buffer)); + if (input.charAt(index + 1) == ')') { + root.setProperties(properties); + return index + 1; + } + index = examineNextNode(input, index, root, properties); + break; + default: + index = appendCharToBuffer(input, index, buffer); + } + ++index; + } + checkIfThereAreDelimiters(buffer); + return index; + } + + private int addNewChild(String input, int index, SgfNode root) throws SgfParsingException { + SgfNode node = new SgfNode(); + root.appendChild(node); + return parseFromIndex(input, index + 2, node); + } + + private String loadKeyFromBuffer(StringBuilder buffer, Map> properties) + throws SgfParsingException { + String key = popStringFromBuffer(buffer); + checkIfKeyUppercase(key); + properties.put(key, new ArrayList<>()); + return key; + } + + private String popStringFromBuffer(StringBuilder stringBuilder) { + String toReturn = stringBuilder.toString(); + stringBuilder.setLength(0); + return toReturn; + } + + private void checkIfKeyUppercase(String key) throws SgfParsingException { + if (!isUppercase(key)) { + throw new SgfParsingException("property must be in uppercase"); + } + } + + private boolean isUppercase(String key) { + return key.equals(key.toUpperCase()); + } + + private int examineNextNode(String input, int index, SgfNode root, Map> properties) + throws SgfParsingException { + char nextChar = input.charAt(index + 1); + if (nextChar == '[') { + ++index; + } else if (nextChar == '(' || nextChar == ';') { + root.setProperties(properties); + if (nextChar == '(') { + index = addNewChild(input, index + 1, root); + } else { + index = addNewChild(input, index, root); + } + } + return index; + } + + private int appendCharToBuffer(String input, int index, StringBuilder buffer) { + char character = input.charAt(index); + while (character == '\\') { + character = input.charAt(++index); + } + if (character == '\t') { + character = ' '; + } + buffer.append(character); + return index; + } + + private void checkIfThereAreDelimiters(StringBuilder buffer) throws SgfParsingException { + if (buffer.length() != 0) { + throw new SgfParsingException("properties without delimiter"); + } + } + +} diff --git a/exercises/practice/sgf-parsing/.meta/src/reference/java/SgfParsingException.java b/exercises/practice/sgf-parsing/.meta/src/reference/java/SgfParsingException.java new file mode 100644 index 000000000..6d1ad23f5 --- /dev/null +++ b/exercises/practice/sgf-parsing/.meta/src/reference/java/SgfParsingException.java @@ -0,0 +1,7 @@ +public class SgfParsingException extends Exception { + + public SgfParsingException(String message) { + super(message); + } + +} diff --git a/exercises/practice/sgf-parsing/.meta/tests.toml b/exercises/practice/sgf-parsing/.meta/tests.toml new file mode 100644 index 000000000..c17626345 --- /dev/null +++ b/exercises/practice/sgf-parsing/.meta/tests.toml @@ -0,0 +1,49 @@ +# 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" + +[11c36323-93fc-495d-bb23-c88ee5844b8c] +description = "escaped property" diff --git a/exercises/practice/sgf-parsing/build.gradle b/exercises/practice/sgf-parsing/build.gradle new file mode 100644 index 000000000..8bd005d42 --- /dev/null +++ b/exercises/practice/sgf-parsing/build.gradle @@ -0,0 +1,24 @@ +apply plugin: "java" +apply plugin: "eclipse" +apply plugin: "idea" + +// set default encoding to UTF-8 +compileJava.options.encoding = "UTF-8" +compileTestJava.options.encoding = "UTF-8" + +repositories { + mavenCentral() +} + +dependencies { + testImplementation "junit:junit:4.13" + testImplementation "org.assertj:assertj-core:3.15.0" +} + +test { + testLogging { + exceptionFormat = 'full' + showStandardStreams = true + events = ["passed", "failed", "skipped"] + } +} diff --git a/exercises/practice/sgf-parsing/src/main/java/SgfNode.java b/exercises/practice/sgf-parsing/src/main/java/SgfNode.java new file mode 100644 index 000000000..85fc19dbb --- /dev/null +++ b/exercises/practice/sgf-parsing/src/main/java/SgfNode.java @@ -0,0 +1,60 @@ +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class SgfNode { + + private Map> properties; + private List children; + + public SgfNode() { + properties = new HashMap<>(); + children = new ArrayList<>(); + } + + public SgfNode(Map> properties) { + this.properties = properties; + children = new ArrayList<>(); + } + + public SgfNode(Map> properties, List children) { + this.properties = properties; + this.children = children; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + SgfNode sgfNode = (SgfNode) o; + return properties.equals(sgfNode.properties) && children.equals(sgfNode.children); + } + + @Override + public int hashCode() { + return Objects.hash(properties, children); + } + + @Override + public String toString() { + return "SgfNode{" + + "properties=" + properties + + ", children=" + children + + '}'; + } + + public void appendChild(SgfNode node) { + children.add(node); + } + + public void setProperties(Map> properties) { + this.properties = properties; + } + +} diff --git a/exercises/practice/sgf-parsing/src/main/java/SgfParsing.java b/exercises/practice/sgf-parsing/src/main/java/SgfParsing.java new file mode 100644 index 000000000..6178f1beb --- /dev/null +++ b/exercises/practice/sgf-parsing/src/main/java/SgfParsing.java @@ -0,0 +1,10 @@ +/* + +Since this exercise has a difficulty of > 4 it doesn't come +with any starter implementation. +This is so that you get to practice creating classes and methods +which is an important part of programming in Java. + +Please remove this comment when submitting your solution. + +*/ diff --git a/exercises/practice/sgf-parsing/src/main/java/SgfParsingException.java b/exercises/practice/sgf-parsing/src/main/java/SgfParsingException.java new file mode 100644 index 000000000..6d1ad23f5 --- /dev/null +++ b/exercises/practice/sgf-parsing/src/main/java/SgfParsingException.java @@ -0,0 +1,7 @@ +public class SgfParsingException extends Exception { + + public SgfParsingException(String message) { + super(message); + } + +} diff --git a/exercises/practice/sgf-parsing/src/test/java/SgfParsingTest.java b/exercises/practice/sgf-parsing/src/test/java/SgfParsingTest.java new file mode 100644 index 000000000..25301285f --- /dev/null +++ b/exercises/practice/sgf-parsing/src/test/java/SgfParsingTest.java @@ -0,0 +1,136 @@ +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.util.List; +import java.util.Map; + +import org.junit.Ignore; +import org.junit.Test; + +public class SgfParsingTest { + + @Test + public void emptyInput() { + String input = ""; + assertThrows("tree missing", + SgfParsingException.class, + () -> new SgfParsing().parse(input)); + } + + @Test + @Ignore("Remove to run test") + public void treeWithNoNodes() { + String input = "()"; + assertThrows("tree with no nodes", + SgfParsingException.class, + () -> new SgfParsing().parse(input)); + } + + @Test + @Ignore("Remove to run test") + public void nodeWithoutTree() { + String input = ";"; + assertThrows("tree missing", + SgfParsingException.class, + () -> new SgfParsing().parse(input)); + } + + @Test + @Ignore("Remove to run test") + public void nodeWithoutProperties() throws SgfParsingException { + String input = "(;)"; + SgfNode expected = new SgfNode(); + SgfNode actual = new SgfParsing().parse(input); + assertEquals(expected, actual); + } + + @Test + @Ignore("Remove to run test") + public void singleNodeTree() throws SgfParsingException { + String input = "(;A[B])"; + SgfNode expected = new SgfNode(Map.of("A", List.of("B"))); + SgfNode actual = new SgfParsing().parse(input); + assertEquals(expected, actual); + } + + @Test + @Ignore("Remove to run test") + public void multipleProperties() throws SgfParsingException { + String input = "(;A[b]C[d])"; + SgfNode expected = new SgfNode(Map.of("A", List.of("b"), + "C", List.of("d"))); + SgfNode actual = new SgfParsing().parse(input); + assertEquals(expected, actual); + } + + @Test + @Ignore("Remove to run test") + public void propertiesWithoutDelimiter() { + String input = "(;A)"; + assertThrows("properties without delimiter", + SgfParsingException.class, + () -> new SgfParsing().parse(input)); + } + + @Test + @Ignore("Remove to run test") + public void allLowercaseProperty() { + String input = "(;a[b])"; + assertThrows("property must be in uppercase", + SgfParsingException.class, + () -> new SgfParsing().parse(input)); + } + + @Test + @Ignore("Remove to run test") + public void upperAndLowercaseProperty() { + String input = "(;Aa[b])"; + assertThrows("property must be in uppercase", + SgfParsingException.class, + () -> new SgfParsing().parse(input)); + } + + @Test + @Ignore("Remove to run test") + public void twoNodes() throws SgfParsingException { + String input = "(;A[B];B[C])"; + SgfNode expected = new SgfNode(Map.of("A", List.of("B")), + List.of( + new SgfNode(Map.of("B", List.of("C"))) + )); + SgfNode actual = new SgfParsing().parse(input); + assertEquals(expected, actual); + } + + @Test + @Ignore("Remove to run test") + public void twoChildTrees() throws SgfParsingException { + String input = "(;A[B](;B[C])(;C[D]))"; + SgfNode expected = new SgfNode(Map.of("A", List.of("B")), + List.of( + new SgfNode(Map.of("B", List.of("C"))), + new SgfNode(Map.of("C", List.of("D"))) + )); + SgfNode actual = new SgfParsing().parse(input); + assertEquals(expected, actual); + } + + @Test + @Ignore("Remove to run test") + public void multiplePropertyValues() throws SgfParsingException { + String input = "(;A[b][c][d])"; + SgfNode expected = new SgfNode(Map.of("A", List.of("b", "c", "d"))); + SgfNode actual = new SgfParsing().parse(input); + assertEquals(expected, actual); + } + + @Test + @Ignore("Remove to run test") + public void escapedProperty() throws SgfParsingException { + String input = "(;A[\\]b\nc\nd\t\te \n\\]])"; + SgfNode expected = new SgfNode(Map.of("A", List.of("]b\nc\nd e \n]"))); + SgfNode actual = new SgfParsing().parse(input); + assertEquals(expected, actual); + } + +} diff --git a/exercises/settings.gradle b/exercises/settings.gradle index a4ba370e7..5428c6fd7 100644 --- a/exercises/settings.gradle +++ b/exercises/settings.gradle @@ -118,6 +118,7 @@ include 'practice:series' include 'practice:sieve' include 'practice:simple-cipher' include 'practice:simple-linked-list' +include 'practice:sgf-parsing' include 'practice:space-age' include 'practice:spiral-matrix' include 'practice:strain'