# HG changeset patch # User Artem Tikhomirov # Date 1351187948 -7200 # Node ID 02140be396d5aa2d59f6bee3a8cc3a34e940ac32 # Parent c1c8f6859d3ff6755d0b85128198cbdc7f3a9b1b Issue 38. Towards gentle handling of config files - parse them and keep every possible user change diff -r c1c8f6859d3f -r 02140be396d5 build.xml --- a/build.xml Mon Oct 22 20:33:20 2012 +0200 +++ b/build.xml Thu Oct 25 19:59:08 2012 +0200 @@ -83,6 +83,7 @@ + diff -r c1c8f6859d3f -r 02140be396d5 src/org/tmatesoft/hg/internal/ConfigFileParser.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/org/tmatesoft/hg/internal/ConfigFileParser.java Thu Oct 25 19:59:08 2012 +0200 @@ -0,0 +1,383 @@ +/* + * Copyright (c) 2012 TMate Software Ltd + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 2 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * For information on how to redistribute this software under + * the terms of a license other than GNU General Public License + * contact TMate Software at support@hg4j.com + */ +package org.tmatesoft.hg.internal; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; + +/** + * Simplistic parser to allow altering configuration files without touching user modifications/formatting/comments + * + * @author Artem Tikhomirov + * @author TMate Software Ltd. + */ +public class ConfigFileParser { + private enum ParseState {Initial, Section, Entry}; + private ParseState state = ParseState.Initial; + private int lastNonEmptyLineEndOffset = -1; + private String sectionName; + private int sectionStart = -1; + private String entryKey; + private int entryStart = -1; + private int valueStart = -1, valueEnd = -1; + private ArrayList entries; + private ArrayList
sections = new ArrayList
(); + private byte[] contents; + + private List deletions = new ArrayList(5); + private List additions = new ArrayList(5), changes = new ArrayList(5); + + + public boolean exists(String section, String key) { + assert contents != null; + for (Section s : sections) { + if (s.name.equals(section)) { + for (Entry e : s.entries) { + if (e.name.equals(key)) { + return true; + } + } + return false; + } + } + return false; + } + + public void add(String section, String key, String newValue) { + additions.add(section); + additions.add(key); + additions.add(newValue); + } + + public void change(String section, String key, String newValue) { + changes.add(section); + changes.add(key); + changes.add(newValue); + } + + public void delete(String section, String key) { + deletions.add(section); + deletions.add(key); + } + + public void parse(InputStream is) throws IOException { + state = ParseState.Initial; + sections.clear(); + contents = null; + ByteArrayOutputStream bos = new ByteArrayOutputStream(1024); + ByteArrayOutputStream line = new ByteArrayOutputStream(80); + int offset = 0; + int lineOffset = -1; + int lineNumber = 1; + boolean crDetected = false; // true when previous char was \r + int b; + while ( (b = is.read()) != -1) { + bos.write(b); + if (b == '\n' || b == '\r') { + if (line.size() > 0) { + processLine(lineNumber, lineOffset, line.toByteArray()); + line.reset(); + lineOffset = -1; + lastNonEmptyLineEndOffset = bos.size() - 1; // offset points to EOL char + } + // else: XXX does empty line closes entry??? + // when \n follows \r, increment line count only once + if (!(b == '\n' && crDetected)) { + lineNumber++; + } + crDetected = b == '\r'; + } else { + crDetected = false; + if (line.size() == 0) { + lineOffset = offset; + } + line.write(b); + } + offset++; + } + // handle last line in case it's not EOL-terminated + if (line.size() > 0) { + processLine(lineNumber, lineOffset, line.toByteArray()); + // might need it for #closeSection() below + lastNonEmptyLineEndOffset = bos.size(); + } + if (state == ParseState.Entry) { + closeEntry(); + } + if (state == ParseState.Section) { + closeSection(); + } + contents = bos.toByteArray(); + } + + public void update(OutputStream out) throws IOException { + if (contents == null) { + throw new IOException("Shall parse first"); + } + HashSet processedSections = new HashSet(); + int contentsOffset = 0; + for (Section section : sections) { + LinkedHashMap additionsInSection = new LinkedHashMap(); + LinkedHashMap changesInSection = new LinkedHashMap(); + LinkedHashSet deletionsInSection = new LinkedHashSet(); + if (!processedSections.contains(section.name)) { + for (Iterator it = additions.iterator(); it.hasNext();) { + String s = it.next(), k = it.next(), v = it.next(); + if (section.name.equals(s)) { + additionsInSection.put(k, v); + } + } + for (Iterator it = changes.iterator(); it.hasNext();) { + String s = it.next(), k = it.next(), v = it.next(); + if (section.name.equals(s)) { + changesInSection.put(k, v); + } + } + for (Iterator it = deletions.iterator(); it.hasNext();) { + String s = it.next(), k = it.next(); + if (section.name.equals(s)) { + deletionsInSection.add(k); + } + } + } + for (Entry e : section.entries) { + if (deletionsInSection.contains(e.name)) { + // write up to key start only + out.write(contents, contentsOffset, e.start - contentsOffset); + contentsOffset = e.valueEnd + 1; + } else if (changesInSection.containsKey(e.name)) { + if (e.valueStart == -1) { + // e.valueEnd determines insertion point + out.write(contents, contentsOffset, e.valueEnd + 1 - contentsOffset); + } else { + // e.valueEnd points to last character of the value + out.write(contents, contentsOffset, e.valueStart - contentsOffset); + } + String value = changesInSection.get(e.name); + out.write(value == null ? new byte[0] : value.getBytes()); + contentsOffset = e.valueEnd + 1; + } + // else: keep contentsOffset to point to first uncopied character + } + if (section.entries.length == 0) { + // no entries, empty or only comments, perhaps. + // use end of last meaningful line (whether [section] or comment string), + // which points to newline character + out.write(contents, contentsOffset, section.end - contentsOffset); + contentsOffset = section.end; + // since it's tricky to track \n or \r\n with lastNonEmptyLineEndOffset, + // we copy up to the line delimiter and insert new lines, if any, with \n prepended, + // so that original EOL will be moved to the very end of the section. + // Indeed, would be better to insert *after* lastNonEmptyLineEndOffset, + // but I don't want to complicate #parse (if line.size() > 0 part) method. + // Hope, this won't make too much trouble (if any, at all - + // if String.format translates \n to system EOL, then nobody would notice) + } + if (!additionsInSection.isEmpty()) { + // make sure additions are written once everything else is there + out.write(contents, contentsOffset, section.end - contentsOffset); + contentsOffset = section.end; + for (String k : additionsInSection.keySet()) { + String v = additionsInSection.get(k); + out.write(String.format("\n%s = %s", k, v == null ? "" : v).getBytes()); + } + } + // if section comes more than once, update only first one. + processedSections.add(section.name); + } + out.write(contents, contentsOffset, contents.length - contentsOffset); + } + + private void processLine(int lineNumber, int offset, byte[] line) throws IOException { + int localOffset = 0, i = 0; + while (i < line.length && Character.isWhitespace(line[i])) { + i++; + } + if (i == line.length) { + return; + } + localOffset = i; + if (line[i] == '[') { + if (state == ParseState.Entry) { + closeEntry(); + } + if (state == ParseState.Section) { + closeSection(); + } + + while (i < line.length && line[i] != ']') { + i++; + } + if (i == line.length) { + throw new IOException(String.format("Can't find closing ']' for section name in line %d", lineNumber)); + } + sectionName = new String(line, localOffset+1, i-localOffset-1); + sectionStart = offset + localOffset; + state = ParseState.Section; + } else if (line[i] == '#' || line[i] == ';') { + // comment line, nothing to process + return; + } else { + // entry + if (state == ParseState.Initial) { + throw new IOException(String.format("Line %d doesn't belong to any section", lineNumber)); + } + if (localOffset > 0) { + if (state == ParseState.Section) { + throw new IOException(String.format("Non-indented key is expected in line %d", lineNumber)); + } + assert state == ParseState.Entry; + // whitespace-indented continuation of the previous entry + if (valueStart == -1) { + // value didn't start at the same line the key was found at + valueStart = offset + localOffset; + } + // value ends with eol (assumption is trailing comments are not allowed) + valueEnd = offset + line.length - 1; + } else { + if (state == ParseState.Entry) { + closeEntry(); + } + assert state == ParseState.Section; + // it's a new entry + state = ParseState.Entry; + // get name of the entry + while (i < line.length && !Character.isWhitespace(line[i]) && line[i] != '=') { + i++; + } + if (i == line.length) { + throw new IOException(String.format("Can't process entry in line %d", lineNumber)); + } + entryKey = new String(line, localOffset, i - localOffset); + entryStart = offset + localOffset; + // look for '=' after key name + while (i < line.length && line[i] != '=') { + i++; + } + if (i == line.length) { + throw new IOException(String.format("Can't find '=' after key %s in line %d", entryKey, lineNumber)); + } + // skip whitespaces after '=' + i++; // line[i] == '=' + while (i < line.length && Character.isWhitespace(line[i])) { + i++; + } + // valueStart might be -1 in case no value is specified in the same line as key + // but valueEnd is always initialized just in case there's no next, value continuation line + if (i == line.length) { + valueStart = -1; + } else { + valueStart = offset + i; + } + + // if trailing comments are allowed, shall + // look up comment char and set valueEnd to its position-1 + valueEnd = offset + line.length - 1; + } + } + } + + private void closeSection() { + assert state == ParseState.Section; + assert sectionName != null; + assert lastNonEmptyLineEndOffset != -1; + Section s = new Section(sectionName, sectionStart, lastNonEmptyLineEndOffset, entries == null ? Collections.emptyList() : entries); + sections.add(s); + sectionName = null; + sectionStart = -1; + state = ParseState.Initial; + entries = null; + } + + private void closeEntry() { + assert state == ParseState.Entry; + assert entryKey != null; + state = ParseState.Section; + Entry e = new Entry(entryKey, entryStart, valueStart, valueEnd); + if (entries == null) { + entries = new ArrayList(); + } + entries.add(e); + entryKey = null; + entryStart = valueStart = valueEnd -1; + } + + + private static class Block { + public final int start; + Block(int s) { + start = s; + } + } + + private static class Entry extends Block { + public final int valueStart, valueEnd; + public final String name; + + Entry(String n, int s, int vs, int ve) { + super(s); + name = n; + valueStart = vs; + valueEnd = ve; + } + } + + private static class Section extends Block { + public final String name; + public final Entry[] entries; + public final int end; + + Section(String n, int s, int endOffset, List e) { + super(s); + name = n; + end = endOffset; + entries = new Entry[e.size()]; + e.toArray(entries); + } + } + + public static void main(String[] args) throws Exception { + ConfigFileParser p = new ConfigFileParser(); + p.parse(new ByteArrayInputStream(xx.getBytes())); + System.out.println(">>>"); + System.out.println(xx); + System.out.println("==="); + p.add("sect1", "key5", "x"); + ByteArrayOutputStream out = new ByteArrayOutputStream(xx.length()); + p.update(out); + System.out.println(new String(out.toByteArray())); + /* + for (Section s : p.sections) { + System.out.printf("[%s@%d]\n", s.name, s.start); + for (Entry e : s.entries) { + System.out.printf("%s@%d = %d..%d\n", e.name, e.start, e.valueStart, e.valueEnd); + } + } + */ + } + private static final String xx = "#comment1\n [sect1]\nkey = value #not a comment2\n#comment3\nkey2= \nkey3 = \n value1, #cc\n value2\nkey4 = v1,\n v2 \n ,v3\n\n\n[sect2]\nx = a"; +} diff -r c1c8f6859d3f -r 02140be396d5 test/org/tmatesoft/hg/test/TestConfigFileParser.java --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/test/org/tmatesoft/hg/test/TestConfigFileParser.java Thu Oct 25 19:59:08 2012 +0200 @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2012 TMate Software Ltd + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 2 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * For information on how to redistribute this software under + * the terms of a license other than GNU General Public License + * contact TMate Software at support@hg4j.com + */ +package org.tmatesoft.hg.test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import org.junit.Assert; +import org.junit.Test; +import org.tmatesoft.hg.internal.ConfigFileParser; + +/** + * + * @author Artem Tikhomirov + * @author TMate Software Ltd. + */ +public class TestConfigFileParser { + + @Test + public void testParseOnly() throws IOException { + doTest("".getBytes(), new byte[0]); + // line comments + byte[] inp = " # line comment \n; and another one".getBytes(); + doTest(inp, inp); + // comments inside sections + inp = "[section1]\nkey1 = value\n # line comment\n[section2]\nkey2 = ;just presence\n".getBytes(); + doTest(inp, inp); + // empty value + inp = "[section1]\nkey1 = \n".getBytes(); + doTest(inp, inp); + // multiline values + inp = "[section1]\nkey1 = a,\n b,\n c\nkey2=\n xyz\n".getBytes(); + doTest(inp, inp); + // entry without EOL + inp = "[section1]\nkey1 = value".getBytes(); + doTest(inp, inp); + // empty section + inp = "[section1]\nkey1 = value\n[section2]\n".getBytes(); + doTest(inp, inp); + } + + @Test + public void testLookup() throws IOException { + byte[] inp = "[section1]\nkey1 = a,\n b,\n c\nkey2=\n xyz\n\n[section2]\nkey3=\n".getBytes(); + doTest(inp, inp, new Inspector() { + + public void visit(ConfigFileParser p) { + Assert.assertTrue(p.exists("section1", "key1")); + Assert.assertTrue(p.exists("section1", "key2")); + Assert.assertFalse(p.exists("section1", "key3")); + + Assert.assertTrue(p.exists("section2", "key3")); + Assert.assertFalse(p.exists("section2", "key1")); + Assert.assertFalse(p.exists("section2", "key2")); + } + }); + } + + @Test + public void testAddChangeEntries() throws IOException { + byte[] inp = "\n; line comment1\n[sect-a]\nkey1 = value1\n\n[sect-b]\nkey2=value2\n".getBytes(); + byte[] exp = "\n; line comment1\n[sect-a]\nkey1 = value1\nkey3 = value3\n\n[sect-b]\nkey2=valueX\n".getBytes(); + doTest(inp, exp, new Inspector() { + + public void visit(ConfigFileParser p) { + Assert.assertTrue(p.exists("sect-b", "key2")); + p.add("sect-a", "key3", "value3"); + p.change("sect-b", "key2", "valueX"); + } + }); + } + + @Test + public void testAdditionTwoSectionsSameName() throws IOException { + byte[] inp = "[sect-a]\nkey1=value1\n\n[sect-b]\nkey2=\n\n[sect-a]\nkey3=value3\n".getBytes(); + byte[] exp = "[sect-a]\nkey1=value1\nkey4 = value4\n\n[sect-b]\nkey2=\n\n[sect-a]\nkey3=value3\n".getBytes(); + doTest(inp, exp, new Inspector() { + + public void visit(ConfigFileParser p) { + p.add("sect-a", "key4", "value4"); + } + }); + } + + @Test + public void testDeleteTwoSubsequentKeys() throws IOException{ + byte[] inp = "# line comment1\n\n[sect-a]\nkey1=value1\nkey2=value2\n#line comment2\nkey3=value3\n".getBytes(); + byte[] exp = "# line comment1\n\n[sect-a]\n\n\n#line comment2\nkey3=value3\n".getBytes(); + doTest(inp, exp, new Inspector() { + + public void visit(ConfigFileParser p) { + p.delete("sect-a", "key1"); + p.delete("sect-a", "key2"); + } + }); + } + + @Test + public void testDeleteLastKeyInSection() throws IOException { + String text1 = "[sect-a]\nkey0 = value 0\n%skey1=value1\n%s[sect-b]\nkey3=value3\n"; + String text2 = "[sect-a]\nkey0 = value 0\n%s\n%s[sect-b]\nkey3=value3\n"; + withTwoCommentsDeleteKey1(text1, text2); + } + + @Test + public void testDeleteFirstKeyInSection() throws IOException { + String text1 = "[sect-a]\n%skey1=value1\n%skey2 = value 2\n[sect-b]\nkey3=value3\n"; + String text2 = "[sect-a]\n%s\n%skey2 = value 2\n[sect-b]\nkey3=value3\n"; + withTwoCommentsDeleteKey1(text1, text2); + } + + @Test + public void testOnlyKeyInSection() throws IOException { + String text1 = "[sect-a]\n%skey1=value1\n%s[sect-b]\nkey3=value3\n"; + String text2 = "[sect-a]\n%s\n%s[sect-b]\nkey3=value3\n"; + withTwoCommentsDeleteKey1(text1, text2); + } + + private void withTwoCommentsDeleteKey1(String text1, String text2) throws IOException { + String comment = "# line comment\n"; + Inspector insp = new Inspector() { + + public void visit(ConfigFileParser p) { + p.delete("sect-a", "key1"); + } + }; + + byte[] inp = String.format(text1, "", "").getBytes(); + byte[] exp = String.format(text2, "", "").getBytes(); + doTest(inp, exp, insp); + inp = String.format(text1, comment, "").getBytes(); + exp = String.format(text2, comment, "").getBytes(); + doTest(inp, exp, insp); + inp = String.format(text1, "", comment).getBytes(); + exp = String.format(text2, "", comment).getBytes(); + doTest(inp, exp, insp); + inp = String.format(text1, comment, comment).getBytes(); + exp = String.format(text2, comment, comment).getBytes(); + doTest(inp, exp, insp); + } + + @Test + public void testAddEntryToEmptySection() throws IOException { + String text1 = "[sect-a]\n%s[sect-b]\nkey3=value3\n"; + String text2 = "[sect-a]\n%skey1 = value1\n[sect-b]\nkey3=value3\n"; + String comment = "# line comment2\n"; + Inspector insp = new Inspector() { + + public void visit(ConfigFileParser p) { + p.add("sect-a", "key1", "value1"); + } + }; + + byte[] inp = String.format(text1, "").getBytes(); + byte[] exp = String.format(text2, "").getBytes(); + doTest(inp, exp, insp); + inp = String.format(text1, comment).getBytes(); + exp = String.format(text2, comment).getBytes(); + doTest(inp, exp, insp); + } + + + private void doTest(byte[] input, byte[] expected) throws IOException { + doTest(input, expected, null); + } + + private void doTest(byte[] input, byte[] expected, Inspector insp) throws IOException { + ConfigFileParser p = new ConfigFileParser(); + p.parse(new ByteArrayInputStream(input)); + if (insp != null) { + insp.visit(p); + } + ByteArrayOutputStream out = new ByteArrayOutputStream(input.length); + p.update(out); + byte[] result = out.toByteArray(); + Assert.assertArrayEquals(expected, result); + } + + interface Inspector { + void visit(ConfigFileParser p); + } +}