summaryrefslogtreecommitdiffstats
path: root/vespajlib
diff options
context:
space:
mode:
authorMartin Polden <mpolden@mpolden.no>2022-06-21 18:18:39 +0200
committerMartin Polden <mpolden@mpolden.no>2022-06-22 11:32:38 +0200
commit67e9ca9481b0f0090f00ac1f1e61298f87baca34 (patch)
tree062a9c11d11b37d52e907023ef2efc7adebd8211 /vespajlib
parentfc1604631cb71e1df1affb4c0980b9d166501ccd (diff)
Basic INI file parser
Diffstat (limited to 'vespajlib')
-rw-r--r--vespajlib/src/main/java/com/yahoo/config/ini/Ini.java172
-rw-r--r--vespajlib/src/test/java/com/yahoo/config/ini/IniTest.java101
2 files changed, 273 insertions, 0 deletions
diff --git a/vespajlib/src/main/java/com/yahoo/config/ini/Ini.java b/vespajlib/src/main/java/com/yahoo/config/ini/Ini.java
new file mode 100644
index 00000000000..db1c3a1c98b
--- /dev/null
+++ b/vespajlib/src/main/java/com/yahoo/config/ini/Ini.java
@@ -0,0 +1,172 @@
+package com.yahoo.config.ini;
+
+import com.yahoo.yolean.Exceptions;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.PrintStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Scanner;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+/**
+ * Basic <a href="https://en.wikipedia.org/wiki/INI_file">INI file</a> parser.
+ *
+ * <p>Supported syntax:</p>
+ *
+ * <ul>
+ * <li>Sections. Surrounded with '[' and ']'</li>
+ * <li>Optional quoting of values. When quoted, the quote character '"' can be escaped with '\'</li>
+ * <li>Comments, separate and in-line. Indicated with leading ';' or '#'</li>
+ * </ul>
+ *
+ * <p>Behaviour:</p>
+ *
+ * <ul>
+ * <li>Leading and trailing whitespace is always ignored if the value is unquoted</li>
+ * <li>Sections are sorted in alphabetic order. The same goes for keys within a section</li>
+ * <li>Empty string in the parsed Map holds section-less config keys</li>
+ * <li>Duplicated keys within the same section is an error</li>
+ * <li>Parsing discards comments</li>
+ * <li>No limitations on section or key names</li>
+ * </ul>
+ *
+ * @param entries Entries of the INI file, grouped by section.
+ *
+ * @author mpolden
+ */
+public record Ini(SortedMap<String, SortedMap<String, String>> entries) {
+
+ private static final char ESCAPE_C = '\\';
+ private static final char QUOTE_C = '"';
+ private static final String QUOTE = String.valueOf(QUOTE_C);
+
+ public Ini {
+ var copy = new TreeMap<>(entries);
+ copy.replaceAll((k, v) -> Collections.unmodifiableSortedMap(new TreeMap<>(copy.get(k))));
+ entries = Collections.unmodifiableSortedMap(copy);
+ }
+
+ /** Write the text representation of this to given output */
+ public void write(OutputStream output) {
+ PrintStream printer = new PrintStream(output, true);
+ entries.forEach((section, sectionEntries) -> {
+ if (!section.isEmpty()) {
+ printer.printf("[%s]\n", section);
+ }
+ sectionEntries.forEach((key, value) -> {
+ printer.printf("%s = %s\n", key, quote(value));
+ });
+ if (!section.equals(entries.lastKey())) {
+ printer.println();
+ }
+ });
+ }
+
+ /** Parse an INI configuration from given input */
+ public static Ini parse(InputStream input) {
+ SortedMap<String, SortedMap<String, String>> entries = new TreeMap<>();
+ Scanner scanner = new Scanner(input, StandardCharsets.UTF_8);
+ String section = "";
+ int lineNum = 0;
+ while (scanner.hasNextLine()) {
+ lineNum++;
+ String line = scanner.nextLine().trim();
+ // Blank line
+ if (line.isEmpty()) {
+ continue;
+ }
+ // Comment
+ if (isComment(line)) {
+ continue;
+ }
+ // Section
+ if (line.startsWith("[") && line.endsWith("]")) {
+ section = line.substring(1, line.length() - 1);
+ continue;
+ }
+ // Key-value entry
+ try {
+ Entry entry = Entry.parse(line);
+ entries.putIfAbsent(section, new TreeMap<>());
+ String prevValue = entries.computeIfAbsent(section, (k) -> new TreeMap<>())
+ .put(entry.key, entry.value);
+ if (prevValue != null) {
+ throw new IllegalArgumentException("Key '" + entry.key + "' duplicated in section '" +
+ section + "'");
+ }
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid entry on line " + lineNum + ": '" + line + "': " +
+ Exceptions.toMessageString(e));
+ }
+ }
+ return new Ini(entries);
+ }
+
+ private static boolean isComment(String s) {
+ return s.startsWith(";") || s.startsWith("#");
+ }
+
+ private static boolean requiresQuoting(String s) {
+ return s.isEmpty() || s.contains(QUOTE) || !s.equals(s.trim());
+ }
+
+ private static boolean unescapedQuoteAt(int index, String s) {
+ return s.charAt(index) == QUOTE_C && (index == 0 || s.charAt(index - 1) != ESCAPE_C);
+ }
+
+ private static String quote(String s) {
+ if (!requiresQuoting(s)) return s;
+ StringBuilder sb = new StringBuilder();
+ sb.append(QUOTE);
+ for (int i = 0; i < s.length(); i++) {
+ if (unescapedQuoteAt(i, s)) {
+ sb.append(ESCAPE_C);
+ }
+ sb.append(s.charAt(i));
+ }
+ sb.append(QUOTE);
+ return sb.toString();
+ }
+
+ private record Entry(String key, String value) {
+
+ static Entry parse(String s) {
+ int equalIndex = s.indexOf('=');
+ if (equalIndex < 0) throw new IllegalArgumentException("Expected key=[value]");
+ String key = s.substring(0, equalIndex).trim();
+ String value = s.substring(equalIndex + 1).trim();
+ return new Entry(key, dequote(value));
+ }
+
+ private static String dequote(String s) {
+ boolean quoted = s.startsWith(QUOTE);
+ int end = s.length();
+ boolean closeQuote = false;
+ for (int i = 0; i < s.length(); i++) {
+ closeQuote = quoted && i > 0 && unescapedQuoteAt(i, s);
+ boolean startComment = !quoted && isComment(String.valueOf(s.charAt(i)));
+ if (closeQuote || startComment) {
+ end = i;
+ if (quoted && end < s.length() - 1) {
+ String trailing = s.substring(end + 1).trim();
+ if (!isComment(trailing)) {
+ throw new IllegalArgumentException("Additional character(s) after end quote at column " + end);
+ }
+ }
+ break;
+ }
+ }
+ if (quoted && !closeQuote) {
+ throw new IllegalArgumentException("Missing closing quote");
+ }
+ int start = quoted ? 1 : 0;
+ String value = s.substring(start, end);
+ return quoted ? value : value.trim();
+ }
+
+ }
+
+}
diff --git a/vespajlib/src/test/java/com/yahoo/config/ini/IniTest.java b/vespajlib/src/test/java/com/yahoo/config/ini/IniTest.java
new file mode 100644
index 00000000000..7900f71d410
--- /dev/null
+++ b/vespajlib/src/test/java/com/yahoo/config/ini/IniTest.java
@@ -0,0 +1,101 @@
+package com.yahoo.config.ini;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * @author mpolden
+ */
+class IniTest {
+
+ @Test
+ public void parse() {
+ String example = """
+ key1 = no section
+ []
+ key2 = also no section ; in-line comment
+ ; a comment
+ # another comment
+
+ [foo]
+ key3 = "with spaces; and an escaped quote: \\" " # in-line comment
+ key4 = \\"single leading escaped quote
+ key1 = leading whitespace unquoted
+ key2 = " leading whitespace quoted"
+ key6 =
+
+ [bar]
+ key1=in section
+
+ [foo]
+ key5 = quote \\" in the middle
+ """;
+ Ini ini = parse(example);
+ assertEquals(Map.of("", Map.of("key1", "no section",
+ "key2", "also no section"),
+ "foo", Map.of("key1", "leading whitespace unquoted",
+ "key2", " leading whitespace quoted",
+ "key3", "with spaces; and an escaped quote: \\\" ",
+ "key4", "\\\"single leading escaped quote",
+ "key5", "quote \\\" in the middle",
+ "key6", ""),
+ "bar", Map.of("key1", "in section")),
+ ini.entries());
+
+ String expected = """
+ key1 = no section
+ key2 = also no section
+
+ [bar]
+ key1 = in section
+
+ [foo]
+ key1 = leading whitespace unquoted
+ key2 = " leading whitespace quoted"
+ key3 = "with spaces; and an escaped quote: \\" "
+ key4 = "\\"single leading escaped quote"
+ key5 = "quote \\" in the middle"
+ key6 = ""
+ """;
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ini.write(out);
+ String serialized = out.toString(StandardCharsets.UTF_8);
+ assertEquals(expected, serialized);
+ assertEquals(ini, parse(serialized));
+ }
+
+ @Test
+ public void parse_invalid() {
+ var tests = Map.of("key1\n",
+ "Invalid entry on line 1: 'key1': Expected key=[value]",
+
+ "key0 = ok\nkey1 = \"foo bar\" trailing stuff\n",
+ "Invalid entry on line 2: 'key1 = \"foo bar\" trailing stuff': Additional character(s) after end quote at column 8",
+
+ "[section1]\nkey0=foo\nkey0=bar\n",
+ "Invalid entry on line 3: 'key0=bar': Key 'key0' duplicated in section 'section1'",
+
+ "key1 = \"foo",
+ "Invalid entry on line 1: 'key1 = \"foo': Missing closing quote");
+ tests.forEach((input, errorMessage) -> {
+ try {
+ parse(input);
+ fail("Expected exception for input '" + input + "'");
+ } catch (IllegalArgumentException e) {
+ assertEquals(errorMessage, e.getMessage());
+ }
+ });
+ }
+
+ private static Ini parse(String ini) {
+ return Ini.parse(new ByteArrayInputStream(ini.getBytes(StandardCharsets.UTF_8)));
+ }
+
+}