diff options
author | Martin Polden <mpolden@mpolden.no> | 2022-06-21 18:18:39 +0200 |
---|---|---|
committer | Martin Polden <mpolden@mpolden.no> | 2022-06-22 11:32:38 +0200 |
commit | 67e9ca9481b0f0090f00ac1f1e61298f87baca34 (patch) | |
tree | 062a9c11d11b37d52e907023ef2efc7adebd8211 /vespajlib | |
parent | fc1604631cb71e1df1affb4c0980b9d166501ccd (diff) |
Basic INI file parser
Diffstat (limited to 'vespajlib')
-rw-r--r-- | vespajlib/src/main/java/com/yahoo/config/ini/Ini.java | 172 | ||||
-rw-r--r-- | vespajlib/src/test/java/com/yahoo/config/ini/IniTest.java | 101 |
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))); + } + +} |