Track the comments preceding a setting in the ConfigOrigin for the value.

The rule is that any block of comments uninterrupted by another token
or blank line, goes with the following array element or object field.

The comments are shown in the output of ConfigValue.render().

They are also available in the ConfigOrigin API for custom use.

Comments that don't precede an array element of object field
get discarded, they are not available anywhere.
This commit is contained in:
Havoc Pennington 2011-12-13 11:09:23 -05:00
parent 59a2d8d0df
commit 516b38f44a
24 changed files with 647 additions and 215 deletions

View File

@ -4,6 +4,7 @@
package com.typesafe.config;
import java.net.URL;
import java.util.List;
/**
@ -12,13 +13,13 @@ import java.net.URL;
* with {@link ConfigValue#origin}. Exceptions may have an origin, see
* {@link ConfigException#origin}, but be careful because
* <code>ConfigException.origin()</code> may return null.
*
*
* <p>
* It's best to use this interface only for debugging; its accuracy is
* "best effort" rather than guaranteed, and a potentially-noticeable amount of
* memory could probably be saved if origins were not kept around, so in the
* future there might be some option to discard origins.
*
*
* <p>
* <em>Do not implement this interface</em>; it should only be implemented by
* the config library. Arbitrary implementations will not work because the
@ -66,4 +67,16 @@ public interface ConfigOrigin {
* @return line number or -1 if none is available
*/
public int lineNumber();
/**
* Returns any comments that appeared to "go with" this place in the file.
* Often an empty list, but never null. The details of this are subject to
* change, but at the moment comments that are immediately before an array
* element or object field, with no blank line after the comment, "go with"
* that element or field.
*
* @return any comments that seemed to "go with" this origin, empty list if
* none
*/
public List<String> comments();
}

View File

@ -111,12 +111,12 @@ abstract class AbstractConfigObject extends AbstractConfigValue implements
return ConfigValueType.OBJECT;
}
protected abstract AbstractConfigObject newCopy(ResolveStatus status,
boolean ignoresFallbacks);
protected abstract AbstractConfigObject newCopy(ResolveStatus status, boolean ignoresFallbacks,
ConfigOrigin origin);
@Override
protected AbstractConfigObject newCopy(boolean ignoresFallbacks) {
return newCopy(resolveStatus(), ignoresFallbacks);
protected AbstractConfigObject newCopy(boolean ignoresFallbacks, ConfigOrigin origin) {
return newCopy(resolveStatus(), ignoresFallbacks, origin);
}
@Override
@ -173,7 +173,7 @@ abstract class AbstractConfigObject extends AbstractConfigValue implements
return new SimpleConfigObject(mergeOrigins(this, fallback), merged, newResolveStatus,
newIgnoresFallbacks);
else if (newResolveStatus != resolveStatus() || newIgnoresFallbacks != ignoresFallbacks())
return newCopy(newResolveStatus, newIgnoresFallbacks);
return newCopy(newResolveStatus, newIgnoresFallbacks, origin());
else
return this;
}
@ -234,7 +234,7 @@ abstract class AbstractConfigObject extends AbstractConfigValue implements
}
}
if (changes == null) {
return newCopy(newResolveStatus, ignoresFallbacks());
return newCopy(newResolveStatus, ignoresFallbacks(), origin());
} else {
Map<String, AbstractConfigValue> modified = new HashMap<String, AbstractConfigValue>();
for (String k : keySet()) {
@ -306,6 +306,12 @@ abstract class AbstractConfigObject extends AbstractConfigValue implements
sb.append("# ");
sb.append(v.origin().description());
sb.append("\n");
for (String comment : v.origin().comments()) {
indent(sb, indent + 1);
sb.append("# ");
sb.append(comment);
sb.append("\n");
}
indent(sb, indent + 1);
}
v.render(sb, indent + 1, k, formatted);

View File

@ -18,14 +18,14 @@ import com.typesafe.config.ConfigValue;
*/
abstract class AbstractConfigValue implements ConfigValue, MergeableValue {
final private ConfigOrigin origin;
final private SimpleConfigOrigin origin;
AbstractConfigValue(ConfigOrigin origin) {
this.origin = origin;
this.origin = (SimpleConfigOrigin) origin;
}
@Override
public ConfigOrigin origin() {
public SimpleConfigOrigin origin() {
return this.origin;
}
@ -76,9 +76,7 @@ abstract class AbstractConfigValue implements ConfigValue, MergeableValue {
return this;
}
protected AbstractConfigValue newCopy(boolean ignoresFallbacks) {
return this;
}
protected abstract AbstractConfigValue newCopy(boolean ignoresFallbacks, ConfigOrigin origin);
// this is virtualized rather than a field because only some subclasses
// really need to store the boolean, and they may be able to pack it
@ -105,6 +103,13 @@ abstract class AbstractConfigValue implements ConfigValue, MergeableValue {
throw badMergeException();
}
public AbstractConfigValue withOrigin(ConfigOrigin origin) {
if (this.origin == origin)
return this;
else
return newCopy(ignoresFallbacks(), origin);
}
@Override
public AbstractConfigValue withFallback(ConfigMergeable mergeable) {
if (ignoresFallbacks()) {
@ -118,7 +123,7 @@ abstract class AbstractConfigValue implements ConfigValue, MergeableValue {
AbstractConfigObject fallback = (AbstractConfigObject) other;
if (fallback.resolveStatus() == ResolveStatus.RESOLVED && fallback.isEmpty()) {
if (fallback.ignoresFallbacks())
return newCopy(true /* ignoresFallbacks */);
return newCopy(true /* ignoresFallbacks */, origin);
else
return this;
} else {
@ -128,7 +133,7 @@ abstract class AbstractConfigValue implements ConfigValue, MergeableValue {
// falling back to a non-object doesn't merge anything, and also
// prohibits merging any objects that we fall back to later.
// so we have to switch to ignoresFallbacks mode.
return newCopy(true /* ignoresFallbacks */);
return newCopy(true /* ignoresFallbacks */, origin);
}
}
}

View File

@ -29,4 +29,9 @@ final class ConfigBoolean extends AbstractConfigValue {
String transformToString() {
return value ? "true" : "false";
}
@Override
protected ConfigBoolean newCopy(boolean ignoresFallbacks, ConfigOrigin origin) {
return new ConfigBoolean(origin, value);
}
}

View File

@ -107,6 +107,11 @@ final class ConfigDelayedMerge extends AbstractConfigValue implements
return ignoresFallbacks;
}
@Override
protected AbstractConfigValue newCopy(boolean newIgnoresFallbacks, ConfigOrigin newOrigin) {
return new ConfigDelayedMerge(newOrigin, stack, newIgnoresFallbacks);
}
@Override
protected final ConfigDelayedMerge mergedWithTheUnmergeable(Unmergeable fallback) {
if (ignoresFallbacks)
@ -196,6 +201,12 @@ final class ConfigDelayedMerge extends AbstractConfigValue implements
i += 1;
sb.append(v.origin().description());
sb.append("\n");
for (String comment : v.origin().comments()) {
indent(sb, indent);
sb.append("# ");
sb.append(comment);
sb.append("\n");
}
indent(sb, indent);
}

View File

@ -49,12 +49,12 @@ class ConfigDelayedMergeObject extends AbstractConfigObject implements
}
@Override
protected ConfigDelayedMergeObject newCopy(ResolveStatus status,
boolean ignoresFallbacks) {
protected ConfigDelayedMergeObject newCopy(ResolveStatus status, boolean ignoresFallbacks,
ConfigOrigin origin) {
if (status != resolveStatus())
throw new ConfigException.BugOrBroken(
"attempt to create resolved ConfigDelayedMergeObject");
return new ConfigDelayedMergeObject(origin(), stack, ignoresFallbacks);
return new ConfigDelayedMergeObject(origin, stack, ignoresFallbacks);
}
@Override

View File

@ -43,4 +43,9 @@ final class ConfigDouble extends ConfigNumber {
protected double doubleValue() {
return value;
}
@Override
protected ConfigDouble newCopy(boolean ignoresFallbacks, ConfigOrigin origin) {
return new ConfigDouble(origin, value, originalText);
}
}

View File

@ -43,4 +43,9 @@ final class ConfigInt extends ConfigNumber {
protected double doubleValue() {
return value;
}
@Override
protected ConfigInt newCopy(boolean ignoresFallbacks, ConfigOrigin origin) {
return new ConfigInt(origin, value, originalText);
}
}

View File

@ -43,4 +43,9 @@ final class ConfigLong extends ConfigNumber {
protected double doubleValue() {
return value;
}
@Override
protected ConfigLong newCopy(boolean ignoresFallbacks, ConfigOrigin origin) {
return new ConfigLong(origin, value, originalText);
}
}

View File

@ -39,4 +39,9 @@ final class ConfigNull extends AbstractConfigValue {
protected void render(StringBuilder sb, int indent, boolean formatted) {
sb.append("null");
}
@Override
protected ConfigNull newCopy(boolean ignoresFallbacks, ConfigOrigin origin) {
return new ConfigNull(origin);
}
}

View File

@ -11,7 +11,7 @@ abstract class ConfigNumber extends AbstractConfigValue {
// a sentence) we always have it exactly as the person typed it into the
// config file. It's purely cosmetic; equals/hashCode don't consider this
// for example.
final private String originalText;
final protected String originalText;
protected ConfigNumber(ConfigOrigin origin, String originalText) {
super(origin);

View File

@ -34,4 +34,9 @@ final class ConfigString extends AbstractConfigValue {
protected void render(StringBuilder sb, int indent, boolean formatted) {
sb.append(ConfigImplUtil.renderJsonString(value));
}
@Override
protected ConfigString newCopy(boolean ignoresFallbacks, ConfigOrigin origin) {
return new ConfigString(origin, value);
}
}

View File

@ -61,8 +61,8 @@ final class ConfigSubstitution extends AbstractConfigValue implements
}
@Override
protected ConfigSubstitution newCopy(boolean ignoresFallbacks) {
return new ConfigSubstitution(origin(), pieces, prefixLength, ignoresFallbacks);
protected ConfigSubstitution newCopy(boolean ignoresFallbacks, ConfigOrigin newOrigin) {
return new ConfigSubstitution(newOrigin, pieces, prefixLength, ignoresFallbacks);
}
@Override

View File

@ -32,9 +32,53 @@ final class Parser {
return context.parse();
}
static private final class TokenWithComments {
final Token token;
final List<Token> comments;
TokenWithComments(Token token, List<Token> comments) {
this.token = token;
this.comments = comments;
}
TokenWithComments(Token token) {
this(token, Collections.<Token> emptyList());
}
TokenWithComments prepend(List<Token> earlier) {
if (this.comments.isEmpty()) {
return new TokenWithComments(token, earlier);
} else {
List<Token> merged = new ArrayList<Token>();
merged.addAll(earlier);
merged.addAll(comments);
return new TokenWithComments(token, merged);
}
}
SimpleConfigOrigin setComments(SimpleConfigOrigin origin) {
if (comments.isEmpty()) {
return origin;
} else {
List<String> newComments = new ArrayList<String>();
for (Token c : comments) {
newComments.add(Tokens.getCommentText(c));
}
return origin.setComments(newComments);
}
}
@Override
public String toString() {
// this ends up in user-visible error messages, so we don't want the
// comments
return token.toString();
}
}
static private final class ParseContext {
private int lineNumber;
final private Stack<Token> buffer;
final private Stack<TokenWithComments> buffer;
final private Iterator<Token> tokens;
final private ConfigIncluder includer;
final private ConfigIncludeContext includeContext;
@ -50,7 +94,7 @@ final class Parser {
Iterator<Token> tokens, ConfigIncluder includer,
ConfigIncludeContext includeContext) {
lineNumber = 1;
buffer = new Stack<Token>();
buffer = new Stack<TokenWithComments>();
this.tokens = tokens;
this.flavor = flavor;
this.baseOrigin = origin;
@ -60,14 +104,67 @@ final class Parser {
this.equalsCount = 0;
}
private Token nextToken() {
Token t = null;
if (buffer.isEmpty()) {
t = tokens.next();
} else {
t = buffer.pop();
private void consolidateCommentBlock(Token commentToken) {
// a comment block "goes with" the following token
// unless it's separated from it by a blank line.
// we want to build a list of newline tokens followed
// by a non-newline non-comment token; with all comments
// associated with that final non-newline non-comment token.
List<Token> newlines = new ArrayList<Token>();
List<Token> comments = new ArrayList<Token>();
Token previous = null;
Token next = commentToken;
while (true) {
if (Tokens.isNewline(next)) {
if (previous != null && Tokens.isNewline(previous)) {
// blank line; drop all comments to this point and
// start a new comment block
comments.clear();
}
newlines.add(next);
} else if (Tokens.isComment(next)) {
comments.add(next);
} else {
// a non-newline non-comment token
break;
}
previous = next;
next = tokens.next();
}
// put our concluding token in the queue with all the comments
// attached
buffer.push(new TokenWithComments(next, comments));
// now put all the newlines back in front of it
ListIterator<Token> li = newlines.listIterator(newlines.size());
while (li.hasPrevious()) {
buffer.push(new TokenWithComments(li.previous()));
}
}
private TokenWithComments popToken() {
if (buffer.isEmpty()) {
Token t = tokens.next();
if (Tokens.isComment(t)) {
consolidateCommentBlock(t);
return buffer.pop();
} else {
return new TokenWithComments(t);
}
} else {
return buffer.pop();
}
}
private TokenWithComments nextToken() {
TokenWithComments withComments = null;
withComments = popToken();
Token t = withComments.token;
if (Tokens.isProblem(t)) {
ConfigOrigin origin = t.origin();
String message = Tokens.getProblemMessage(t);
@ -79,32 +176,35 @@ final class Parser {
message = addKeyName(message);
}
throw new ConfigException.Parse(origin, message, cause);
}
if (flavor == ConfigSyntax.JSON) {
if (Tokens.isUnquotedText(t)) {
throw parseError(addKeyName("Token not allowed in valid JSON: '"
+ Tokens.getUnquotedText(t) + "'"));
} else if (Tokens.isSubstitution(t)) {
throw parseError(addKeyName("Substitutions (${} syntax) not allowed in JSON"));
} else {
if (flavor == ConfigSyntax.JSON) {
if (Tokens.isUnquotedText(t)) {
throw parseError(addKeyName("Token not allowed in valid JSON: '"
+ Tokens.getUnquotedText(t) + "'"));
} else if (Tokens.isSubstitution(t)) {
throw parseError(addKeyName("Substitutions (${} syntax) not allowed in JSON"));
}
}
}
return t;
return withComments;
}
}
private void putBack(Token token) {
private void putBack(TokenWithComments token) {
buffer.push(token);
}
private Token nextTokenIgnoringNewline() {
Token t = nextToken();
while (Tokens.isNewline(t)) {
private TokenWithComments nextTokenIgnoringNewline() {
TokenWithComments t = nextToken();
while (Tokens.isNewline(t.token)) {
// line number tokens have the line that was _ended_ by the
// newline, so we have to add one.
lineNumber = t.lineNumber() + 1;
lineNumber = t.token.lineNumber() + 1;
t = nextToken();
}
return t;
}
@ -116,8 +216,8 @@ final class Parser {
// is left just after the comma or the newline.
private boolean checkElementSeparator() {
if (flavor == ConfigSyntax.JSON) {
Token t = nextTokenIgnoringNewline();
if (t == Tokens.COMMA) {
TokenWithComments t = nextTokenIgnoringNewline();
if (t.token == Tokens.COMMA) {
return true;
} else {
putBack(t);
@ -125,15 +225,16 @@ final class Parser {
}
} else {
boolean sawSeparatorOrNewline = false;
Token t = nextToken();
TokenWithComments t = nextToken();
while (true) {
if (Tokens.isNewline(t)) {
if (Tokens.isNewline(t.token)) {
// newline number is the line just ended, so add one
lineNumber = t.lineNumber() + 1;
lineNumber = t.token.lineNumber() + 1;
sawSeparatorOrNewline = true;
// we want to continue to also eat
// a comma if there is one.
} else if (t == Tokens.COMMA) {
} else if (t.token == Tokens.COMMA) {
return true;
} else {
// non-newline-or-comma
@ -154,12 +255,17 @@ final class Parser {
return;
List<Token> values = null; // create only if we have value tokens
Token t = nextTokenIgnoringNewline(); // ignore a newline up front
while (Tokens.isValue(t) || Tokens.isUnquotedText(t)
|| Tokens.isSubstitution(t)) {
if (values == null)
TokenWithComments firstValueWithComments = null;
TokenWithComments t = nextTokenIgnoringNewline(); // ignore a
// newline up
// front
while (Tokens.isValue(t.token) || Tokens.isUnquotedText(t.token)
|| Tokens.isSubstitution(t.token)) {
if (values == null) {
values = new ArrayList<Token>();
values.add(t);
firstValueWithComments = t;
}
values.add(t.token);
t = nextToken(); // but don't consolidate across a newline
}
// the last one wasn't a value token
@ -168,9 +274,9 @@ final class Parser {
if (values == null)
return;
if (values.size() == 1 && Tokens.isValue(values.get(0))) {
if (values.size() == 1 && Tokens.isValue(firstValueWithComments.token)) {
// a single value token requires no consolidation
putBack(values.get(0));
putBack(firstValueWithComments);
return;
}
@ -235,7 +341,7 @@ final class Parser {
firstOrigin, minimized));
}
putBack(consolidated);
putBack(new TokenWithComments(consolidated, firstValueWithComments.comments));
}
private ConfigOrigin lineOrigin() {
@ -309,17 +415,23 @@ final class Parser {
return part + ")";
}
private AbstractConfigValue parseValue(Token token) {
if (Tokens.isValue(token)) {
return Tokens.getValue(token);
} else if (token == Tokens.OPEN_CURLY) {
return parseObject(true);
} else if (token == Tokens.OPEN_SQUARE) {
return parseArray();
private AbstractConfigValue parseValue(TokenWithComments t) {
AbstractConfigValue v;
if (Tokens.isValue(t.token)) {
v = Tokens.getValue(t.token);
} else if (t.token == Tokens.OPEN_CURLY) {
v = parseObject(true);
} else if (t.token == Tokens.OPEN_SQUARE) {
v = parseArray();
} else {
throw parseError(addQuoteSuggestion(token.toString(),
"Expecting a value but got wrong token: " + token));
throw parseError(addQuoteSuggestion(t.token.toString(),
"Expecting a value but got wrong token: " + t.token));
}
v = v.withOrigin(t.setComments(v.origin()));
return v;
}
private static AbstractConfigObject createValueUnderPath(Path path,
@ -339,24 +451,29 @@ final class Parser {
remaining = remaining.remainder();
}
}
// the setComments(null) is to ensure comments are only
// on the exact leaf node they apply to.
// a comment before "foo.bar" applies to the full setting
// "foo.bar" not also to "foo"
ListIterator<String> i = keys.listIterator(keys.size());
String deepest = i.previous();
AbstractConfigObject o = new SimpleConfigObject(value.origin(),
AbstractConfigObject o = new SimpleConfigObject(value.origin().setComments(null),
Collections.<String, AbstractConfigValue> singletonMap(
deepest, value));
while (i.hasPrevious()) {
Map<String, AbstractConfigValue> m = Collections.<String, AbstractConfigValue> singletonMap(
i.previous(), o);
o = new SimpleConfigObject(value.origin(), m);
o = new SimpleConfigObject(value.origin().setComments(null), m);
}
return o;
}
private Path parseKey(Token token) {
private Path parseKey(TokenWithComments token) {
if (flavor == ConfigSyntax.JSON) {
if (Tokens.isValueWithType(token, ConfigValueType.STRING)) {
String key = (String) Tokens.getValue(token).unwrapped();
if (Tokens.isValueWithType(token.token, ConfigValueType.STRING)) {
String key = (String) Tokens.getValue(token.token).unwrapped();
return Path.newKey(key);
} else {
throw parseError(addKeyName("Expecting close brace } or a field name here, got "
@ -364,9 +481,9 @@ final class Parser {
}
} else {
List<Token> expression = new ArrayList<Token>();
Token t = token;
while (Tokens.isValue(t) || Tokens.isUnquotedText(t)) {
expression.add(t);
TokenWithComments t = token;
while (Tokens.isValue(t.token) || Tokens.isUnquotedText(t.token)) {
expression.add(t.token);
t = nextToken(); // note: don't cross a newline
}
@ -400,13 +517,13 @@ final class Parser {
}
private void parseInclude(Map<String, AbstractConfigValue> values) {
Token t = nextTokenIgnoringNewline();
while (isUnquotedWhitespace(t)) {
TokenWithComments t = nextTokenIgnoringNewline();
while (isUnquotedWhitespace(t.token)) {
t = nextTokenIgnoringNewline();
}
if (Tokens.isValueWithType(t, ConfigValueType.STRING)) {
String name = (String) Tokens.getValue(t).unwrapped();
if (Tokens.isValueWithType(t.token, ConfigValueType.STRING)) {
String name = (String) Tokens.getValue(t.token).unwrapped();
AbstractConfigObject obj = (AbstractConfigObject) includer
.include(includeContext, name);
@ -448,8 +565,8 @@ final class Parser {
boolean lastInsideEquals = false;
while (true) {
Token t = nextTokenIgnoringNewline();
if (t == Tokens.CLOSE_CURLY) {
TokenWithComments t = nextTokenIgnoringNewline();
if (t.token == Tokens.CLOSE_CURLY) {
if (flavor == ConfigSyntax.JSON && afterComma) {
throw parseError(addQuoteSuggestion(t.toString(),
"expecting a field name after a comma, got a close brace } instead"));
@ -458,45 +575,45 @@ final class Parser {
"unbalanced close brace '}' with no open brace"));
}
break;
} else if (t == Tokens.END && !hadOpenCurly) {
} else if (t.token == Tokens.END && !hadOpenCurly) {
putBack(t);
break;
} else if (flavor != ConfigSyntax.JSON && isIncludeKeyword(t)) {
} else if (flavor != ConfigSyntax.JSON && isIncludeKeyword(t.token)) {
parseInclude(values);
afterComma = false;
} else {
Path path = parseKey(t);
Token afterKey = nextTokenIgnoringNewline();
TokenWithComments keyToken = t;
Path path = parseKey(keyToken);
TokenWithComments afterKey = nextTokenIgnoringNewline();
boolean insideEquals = false;
// path must be on-stack while we parse the value
pathStack.push(path);
Token valueToken;
TokenWithComments valueToken;
AbstractConfigValue newValue;
if (flavor == ConfigSyntax.CONF
&& afterKey == Tokens.OPEN_CURLY) {
if (flavor == ConfigSyntax.CONF && afterKey.token == Tokens.OPEN_CURLY) {
// can omit the ':' or '=' before an object value
valueToken = afterKey;
newValue = parseObject(true);
} else {
if (!isKeyValueSeparatorToken(afterKey)) {
if (!isKeyValueSeparatorToken(afterKey.token)) {
throw parseError(addQuoteSuggestion(afterKey.toString(),
"Key '" + path.render() + "' may not be followed by token: "
+ afterKey));
}
if (afterKey == Tokens.EQUALS) {
if (afterKey.token == Tokens.EQUALS) {
insideEquals = true;
equalsCount += 1;
}
consolidateValueTokens();
valueToken = nextTokenIgnoringNewline();
newValue = parseValue(valueToken);
}
newValue = parseValue(valueToken.prepend(keyToken.comments));
lastPath = pathStack.pop();
if (insideEquals) {
equalsCount -= 1;
@ -547,7 +664,7 @@ final class Parser {
afterComma = true;
} else {
t = nextTokenIgnoringNewline();
if (t == Tokens.CLOSE_CURLY) {
if (t.token == Tokens.CLOSE_CURLY) {
if (!hadOpenCurly) {
throw parseError(addQuoteSuggestion(lastPath, lastInsideEquals,
t.toString(), "unbalanced close brace '}' with no open brace"));
@ -557,7 +674,7 @@ final class Parser {
throw parseError(addQuoteSuggestion(lastPath, lastInsideEquals,
t.toString(), "Expecting close brace } or a comma, got " + t));
} else {
if (t == Tokens.END) {
if (t.token == Tokens.END) {
putBack(t);
break;
} else {
@ -567,6 +684,7 @@ final class Parser {
}
}
}
return new SimpleConfigObject(objectOrigin, values);
}
@ -577,18 +695,15 @@ final class Parser {
consolidateValueTokens();
Token t = nextTokenIgnoringNewline();
TokenWithComments t = nextTokenIgnoringNewline();
// special-case the first element
if (t == Tokens.CLOSE_SQUARE) {
if (t.token == Tokens.CLOSE_SQUARE) {
return new SimpleConfigList(arrayOrigin,
Collections.<AbstractConfigValue> emptyList());
} else if (Tokens.isValue(t)) {
} else if (Tokens.isValue(t.token) || t.token == Tokens.OPEN_CURLY
|| t.token == Tokens.OPEN_SQUARE) {
values.add(parseValue(t));
} else if (t == Tokens.OPEN_CURLY) {
values.add(parseObject(true));
} else if (t == Tokens.OPEN_SQUARE) {
values.add(parseArray());
} else {
throw parseError(addKeyName("List should have ] or a first element after the open [, instead had token: "
+ t
@ -604,7 +719,7 @@ final class Parser {
// comma (or newline equivalent) consumed
} else {
t = nextTokenIgnoringNewline();
if (t == Tokens.CLOSE_SQUARE) {
if (t.token == Tokens.CLOSE_SQUARE) {
return new SimpleConfigList(arrayOrigin, values);
} else {
throw parseError(addKeyName("List should have ended with ] or had a comma, instead had token: "
@ -619,14 +734,10 @@ final class Parser {
consolidateValueTokens();
t = nextTokenIgnoringNewline();
if (Tokens.isValue(t)) {
if (Tokens.isValue(t.token) || t.token == Tokens.OPEN_CURLY
|| t.token == Tokens.OPEN_SQUARE) {
values.add(parseValue(t));
} else if (t == Tokens.OPEN_CURLY) {
values.add(parseObject(true));
} else if (t == Tokens.OPEN_SQUARE) {
values.add(parseArray());
} else if (flavor != ConfigSyntax.JSON
&& t == Tokens.CLOSE_SQUARE) {
} else if (flavor != ConfigSyntax.JSON && t.token == Tokens.CLOSE_SQUARE) {
// we allow one trailing comma
putBack(t);
} else {
@ -640,8 +751,8 @@ final class Parser {
}
AbstractConfigValue parse() {
Token t = nextTokenIgnoringNewline();
if (t == Tokens.START) {
TokenWithComments t = nextTokenIgnoringNewline();
if (t.token == Tokens.START) {
// OK
} else {
throw new ConfigException.BugOrBroken(
@ -650,13 +761,11 @@ final class Parser {
t = nextTokenIgnoringNewline();
AbstractConfigValue result = null;
if (t == Tokens.OPEN_CURLY) {
result = parseObject(true);
} else if (t == Tokens.OPEN_SQUARE) {
result = parseArray();
if (t.token == Tokens.OPEN_CURLY || t.token == Tokens.OPEN_SQUARE) {
result = parseValue(t);
} else {
if (flavor == ConfigSyntax.JSON) {
if (t == Tokens.END) {
if (t.token == Tokens.END) {
throw parseError("Empty document");
} else {
throw parseError("Document must have an object or array at root, unexpected token: "
@ -668,11 +777,14 @@ final class Parser {
// of it, so put it back.
putBack(t);
result = parseObject(false);
// in this case we don't try to use commentsStack comments
// since they would all presumably apply to fields not the
// root object
}
}
t = nextTokenIgnoringNewline();
if (t == Tokens.END) {
if (t.token == Tokens.END) {
return result;
} else {
throw parseError("Document has trailing tokens after first object or array: "

View File

@ -145,6 +145,14 @@ final class SimpleConfigList extends AbstractConfigValue implements ConfigList {
sb.append("# ");
sb.append(v.origin().description());
sb.append("\n");
for (String comment : v.origin().comments()) {
indent(sb, indent + 1);
sb.append("# ");
sb.append(comment);
sb.append("\n");
}
indent(sb, indent + 1);
}
v.render(sb, indent + 1, formatted);
@ -353,4 +361,9 @@ final class SimpleConfigList extends AbstractConfigValue implements ConfigList {
public ConfigValue set(int index, ConfigValue element) {
throw weAreImmutable("set");
}
@Override
protected SimpleConfigList newCopy(boolean ignoresFallbacks, ConfigOrigin newOrigin) {
return new SimpleConfigList(newOrigin, value);
}
}

View File

@ -45,8 +45,9 @@ final class SimpleConfigObject extends AbstractConfigObject {
}
@Override
protected SimpleConfigObject newCopy(ResolveStatus newStatus, boolean newIgnoresFallbacks) {
return new SimpleConfigObject(origin(), value, newStatus, newIgnoresFallbacks);
protected SimpleConfigObject newCopy(ResolveStatus newStatus, boolean newIgnoresFallbacks,
ConfigOrigin newOrigin) {
return new SimpleConfigObject(newOrigin, value, newStatus, newIgnoresFallbacks);
}
@Override

View File

@ -8,6 +8,7 @@ import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
@ -22,19 +23,21 @@ final class SimpleConfigOrigin implements ConfigOrigin {
final private int endLineNumber;
final private OriginType originType;
final private String urlOrNull;
final private List<String> commentsOrNull;
protected SimpleConfigOrigin(String description, int lineNumber, int endLineNumber,
OriginType originType,
String urlOrNull) {
String urlOrNull, List<String> commentsOrNull) {
this.description = description;
this.lineNumber = lineNumber;
this.endLineNumber = endLineNumber;
this.originType = originType;
this.urlOrNull = urlOrNull;
this.commentsOrNull = commentsOrNull;
}
static SimpleConfigOrigin newSimple(String description) {
return new SimpleConfigOrigin(description, -1, -1, OriginType.GENERIC, null);
return new SimpleConfigOrigin(description, -1, -1, OriginType.GENERIC, null, null);
}
static SimpleConfigOrigin newFile(String filename) {
@ -44,17 +47,17 @@ final class SimpleConfigOrigin implements ConfigOrigin {
} catch (MalformedURLException e) {
url = null;
}
return new SimpleConfigOrigin(filename, -1, -1, OriginType.FILE, url);
return new SimpleConfigOrigin(filename, -1, -1, OriginType.FILE, url, null);
}
static SimpleConfigOrigin newURL(URL url) {
String u = url.toExternalForm();
return new SimpleConfigOrigin(u, -1, -1, OriginType.URL, u);
return new SimpleConfigOrigin(u, -1, -1, OriginType.URL, u, null);
}
static SimpleConfigOrigin newResource(String resource, URL url) {
return new SimpleConfigOrigin(resource, -1, -1, OriginType.RESOURCE,
url != null ? url.toExternalForm() : null);
url != null ? url.toExternalForm() : null, null);
}
static SimpleConfigOrigin newResource(String resource) {
@ -66,13 +69,22 @@ final class SimpleConfigOrigin implements ConfigOrigin {
return this;
} else {
return new SimpleConfigOrigin(this.description, lineNumber, lineNumber,
this.originType, this.urlOrNull);
this.originType, this.urlOrNull, this.commentsOrNull);
}
}
SimpleConfigOrigin addURL(URL url) {
return new SimpleConfigOrigin(this.description, this.lineNumber, this.endLineNumber, this.originType,
url != null ? url.toExternalForm() : null);
return new SimpleConfigOrigin(this.description, this.lineNumber, this.endLineNumber,
this.originType, url != null ? url.toExternalForm() : null, this.commentsOrNull);
}
SimpleConfigOrigin setComments(List<String> comments) {
if (ConfigImplUtil.equalsHandlingNull(comments, this.commentsOrNull)) {
return this;
} else {
return new SimpleConfigOrigin(this.description, this.lineNumber, this.endLineNumber,
this.originType, this.urlOrNull, comments);
}
}
@Override
@ -172,12 +184,22 @@ final class SimpleConfigOrigin implements ConfigOrigin {
return lineNumber;
}
@Override
public List<String> comments() {
if (commentsOrNull != null) {
return commentsOrNull;
} else {
return Collections.emptyList();
}
}
static final String MERGE_OF_PREFIX = "merge of ";
private static SimpleConfigOrigin mergeTwo(SimpleConfigOrigin a, SimpleConfigOrigin b) {
String mergedDesc;
int mergedStartLine;
int mergedEndLine;
List<String> mergedComments;
OriginType mergedType;
if (a.originType == b.originType) {
@ -233,8 +255,18 @@ final class SimpleConfigOrigin implements ConfigOrigin {
mergedURL = null;
}
if (ConfigImplUtil.equalsHandlingNull(a.commentsOrNull, b.commentsOrNull)) {
mergedComments = a.commentsOrNull;
} else {
mergedComments = new ArrayList<String>();
if (a.commentsOrNull != null)
mergedComments.addAll(a.commentsOrNull);
if (b.commentsOrNull != null)
mergedComments.addAll(b.commentsOrNull);
}
return new SimpleConfigOrigin(mergedDesc, mergedStartLine, mergedEndLine, mergedType,
mergedURL);
mergedURL, mergedComments);
}
private static int similarity(SimpleConfigOrigin a, SimpleConfigOrigin b) {

View File

@ -17,5 +17,6 @@ enum TokenType {
NEWLINE,
UNQUOTED_TEXT,
SUBSTITUTION,
PROBLEM;
PROBLEM,
COMMENT;
}

View File

@ -168,40 +168,27 @@ final class Tokenizer {
return c != '\n' && ConfigImplUtil.isWhitespace(c);
}
private int slurpComment() {
for (;;) {
int c = nextCharRaw();
if (c == -1 || c == '\n') {
return c;
}
}
}
// get next char, skipping comments
private int nextCharSkippingComments() {
for (;;) {
int c = nextCharRaw();
if (c == -1) {
return -1;
} else {
if (allowComments) {
if (c == '#') {
return slurpComment();
} else if (c == '/') {
int maybeSecondSlash = nextCharRaw();
if (maybeSecondSlash == '/') {
return slurpComment();
} else {
putBack(maybeSecondSlash);
return c;
}
private boolean startOfComment(int c) {
if (c == -1) {
return false;
} else {
if (allowComments) {
if (c == '#') {
return true;
} else if (c == '/') {
int maybeSecondSlash = nextCharRaw();
// we want to predictably NOT consume any chars
putBack(maybeSecondSlash);
if (maybeSecondSlash == '/') {
return true;
} else {
return c;
return false;
}
} else {
return c;
return false;
}
} else {
return false;
}
}
}
@ -209,7 +196,7 @@ final class Tokenizer {
// get next char, skipping non-newline whitespace
private int nextCharAfterWhitespace(WhitespaceSaver saver) {
for (;;) {
int c = nextCharSkippingComments();
int c = nextCharRaw();
if (c == -1) {
return -1;
@ -269,6 +256,27 @@ final class Tokenizer {
return ((SimpleConfigOrigin) baseOrigin).setLineNumber(lineNumber);
}
// ONE char has always been consumed, either the # or the first /, but
// not both slashes
private Token pullComment(int firstChar) {
if (firstChar == '/') {
int discard = nextCharRaw();
if (discard != '/')
throw new ConfigException.BugOrBroken("called pullComment but // not seen");
}
StringBuilder sb = new StringBuilder();
for (;;) {
int c = nextCharRaw();
if (c == -1 || c == '\n') {
putBack(c);
return Tokens.newComment(lineOrigin, sb.toString());
} else {
sb.appendCodePoint(c);
}
}
}
// chars JSON allows a number to start with
static final String firstNumberChars = "0123456789-";
// chars JSON allows to be part of a number
@ -283,7 +291,7 @@ final class Tokenizer {
private Token pullUnquotedText() {
ConfigOrigin origin = lineOrigin;
StringBuilder sb = new StringBuilder();
int c = nextCharSkippingComments();
int c = nextCharRaw();
while (true) {
if (c == -1) {
break;
@ -291,6 +299,8 @@ final class Tokenizer {
break;
} else if (isWhitespace(c)) {
break;
} else if (startOfComment(c)) {
break;
} else {
sb.appendCodePoint(c);
}
@ -310,7 +320,7 @@ final class Tokenizer {
return Tokens.newBoolean(origin, false);
}
c = nextCharSkippingComments();
c = nextCharRaw();
}
// put back the char that ended the unquoted text
@ -324,12 +334,12 @@ final class Tokenizer {
StringBuilder sb = new StringBuilder();
sb.appendCodePoint(firstChar);
boolean containedDecimalOrE = false;
int c = nextCharSkippingComments();
int c = nextCharRaw();
while (c != -1 && numberChars.indexOf(c) >= 0) {
if (c == '.' || c == 'e' || c == 'E')
containedDecimalOrE = true;
sb.appendCodePoint(c);
c = nextCharSkippingComments();
c = nextCharRaw();
}
// the last character we looked at wasn't part of the number, put it
// back
@ -382,7 +392,7 @@ final class Tokenizer {
// kind of absurdly slow, but screw it for now
char[] a = new char[4];
for (int i = 0; i < 4; ++i) {
int c = nextCharSkippingComments();
int c = nextCharRaw();
if (c == -1)
throw problem("End of input but expecting 4 hex digits for \\uXXXX escape");
a[i] = (char) c;
@ -431,14 +441,14 @@ final class Tokenizer {
private Token pullSubstitution() throws ProblemException {
// the initial '$' has already been consumed
ConfigOrigin origin = lineOrigin;
int c = nextCharSkippingComments();
int c = nextCharRaw();
if (c != '{') {
throw problem(asString(c), "'$' not followed by {, '" + asString(c)
+ "' not allowed after '$'", true /* suggestQuotes */);
}
boolean optional = false;
c = nextCharSkippingComments();
c = nextCharRaw();
if (c == '?') {
optional = true;
} else {
@ -465,6 +475,8 @@ final class Tokenizer {
Token whitespace = saver.check(t, origin, lineNumber);
if (whitespace != null)
expression.add(whitespace);
// we don't add comments here though; they can't happen in
// valid syntax anyway.
expression.add(t);
}
} while (true);
@ -484,45 +496,49 @@ final class Tokenizer {
return line;
} else {
Token t = null;
switch (c) {
case '"':
t = pullQuotedString();
break;
case '$':
t = pullSubstitution();
break;
case ':':
t = Tokens.COLON;
break;
case ',':
t = Tokens.COMMA;
break;
case '=':
t = Tokens.EQUALS;
break;
case '{':
t = Tokens.OPEN_CURLY;
break;
case '}':
t = Tokens.CLOSE_CURLY;
break;
case '[':
t = Tokens.OPEN_SQUARE;
break;
case ']':
t = Tokens.CLOSE_SQUARE;
break;
}
if (startOfComment(c)) {
t = pullComment(c);
} else {
switch (c) {
case '"':
t = pullQuotedString();
break;
case '$':
t = pullSubstitution();
break;
case ':':
t = Tokens.COLON;
break;
case ',':
t = Tokens.COMMA;
break;
case '=':
t = Tokens.EQUALS;
break;
case '{':
t = Tokens.OPEN_CURLY;
break;
case '}':
t = Tokens.CLOSE_CURLY;
break;
case '[':
t = Tokens.OPEN_SQUARE;
break;
case ']':
t = Tokens.CLOSE_SQUARE;
break;
}
if (t == null) {
if (firstNumberChars.indexOf(c) >= 0) {
t = pullNumber(c);
} else if (notInUnquotedText.indexOf(c) >= 0) {
throw problem(asString(c), "Reserved character '" + asString(c)
+ "' is not allowed outside quotes", true /* suggestQuotes */);
} else {
putBack(c);
t = pullUnquotedText();
if (t == null) {
if (firstNumberChars.indexOf(c) >= 0) {
t = pullNumber(c);
} else if (notInUnquotedText.indexOf(c) >= 0) {
throw problem(asString(c), "Reserved character '" + asString(c)
+ "' is not allowed outside quotes", true /* suggestQuotes */);
} else {
putBack(c);
t = pullUnquotedText();
}
}
}
@ -548,6 +564,7 @@ final class Tokenizer {
Token whitespace = whitespaceSaver.check(t, origin, lineNumber);
if (whitespace != null)
tokens.add(whitespace);
tokens.add(t);
}

View File

@ -52,7 +52,7 @@ final class Tokens {
@Override
public String toString() {
return "'\n'@" + lineNumber();
return "'\\n'@" + lineNumber();
}
@Override
@ -167,6 +167,45 @@ final class Tokens {
}
}
static private class Comment extends Token {
final private String text;
Comment(ConfigOrigin origin, String text) {
super(TokenType.COMMENT, origin);
this.text = text;
}
String text() {
return text;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("'#");
sb.append(text);
sb.append("' (COMMENT)");
return sb.toString();
}
@Override
protected boolean canEqual(Object other) {
return other instanceof Comment;
}
@Override
public boolean equals(Object other) {
return super.equals(other) && ((Comment) other).text.equals(text);
}
@Override
public int hashCode() {
int h = 41 * (41 + super.hashCode());
h = 41 * (h + text.hashCode());
return h;
}
}
// This is not a Value, because it requires special processing
static private class Substitution extends Token {
final private boolean optional;
@ -262,6 +301,18 @@ final class Tokens {
}
}
static boolean isComment(Token token) {
return token instanceof Comment;
}
static String getCommentText(Token token) {
if (token instanceof Comment) {
return ((Comment) token).text();
} else {
throw new ConfigException.BugOrBroken("tried to get comment text from " + token);
}
}
static boolean isUnquotedText(Token token) {
return token instanceof UnquotedText;
}
@ -316,6 +367,10 @@ final class Tokens {
return new Problem(origin, what, message, suggestQuotes, cause);
}
static Token newComment(ConfigOrigin origin, String text) {
return new Comment(origin, text);
}
static Token newUnquotedText(ConfigOrigin origin, String s) {
return new UnquotedText(origin, s);
}

View File

@ -21,4 +21,5 @@ object RenderExample extends App {
render("test01")
render("test06")
render("test05")
}

View File

@ -360,4 +360,135 @@ class ConfParserTest extends TestUtils {
Parseable.newProperties(new Properties(), options).toString
Parseable.newReader(new StringReader("{}"), options).toString
}
private def assertComments(comments: Seq[String], conf: Config, path: String) {
assertEquals(comments, conf.getValue(path).origin().comments().asScala.toSeq)
}
private def assertComments(comments: Seq[String], conf: Config, path: String, index: Int) {
val v = conf.getList(path).get(index)
assertEquals(comments, v.origin().comments().asScala.toSeq)
}
@Test
def trackCommentsForFields() {
// comment in front of a field is used
val conf1 = parseConfig("""
{ # Hello
foo=10 }
""")
assertComments(Seq(" Hello"), conf1, "foo")
// comment with a blank line after is dropped
val conf2 = parseConfig("""
{ # Hello
foo=10 }
""")
assertComments(Seq(), conf2, "foo")
// comment in front of a field is used with no root {}
val conf3 = parseConfig("""
# Hello
foo=10
""")
assertComments(Seq(" Hello"), conf3, "foo")
// comment with a blank line after is dropped with no root {}
val conf4 = parseConfig("""
# Hello
foo=10
""")
assertComments(Seq(), conf4, "foo")
// nested objects
val conf5 = parseConfig("""
# Outside
bar {
# Ignore me
# Middle
# two lines
baz {
# Inner
foo=10 # should be ignored
# This should be ignored too
} ## not used
# ignored
}
# ignored!
""")
assertComments(Seq(" Inner"), conf5, "bar.baz.foo")
assertComments(Seq(" Middle", " two lines"), conf5, "bar.baz")
assertComments(Seq(" Outside"), conf5, "bar")
// multiple fields
val conf6 = parseConfig("""{
# this is not with a field
# this is field A
a : 10
# this is field B
b : 12 # goes with field C
# this is field C
c : 14,
# this is not used
# nor is this
# multi-line block
# this is with field D
# this is with field D also
d : 16
# this is after the fields
}""")
assertComments(Seq(" this is field A"), conf6, "a")
assertComments(Seq(" this is field B"), conf6, "b")
assertComments(Seq(" goes with field C", " this is field C"), conf6, "c")
assertComments(Seq(" this is with field D", " this is with field D also"), conf6, "d")
// array
val conf7 = parseConfig("""
array = [
# goes with 0
0,
# goes with 1
1, # with 2
# goes with 2
2
# not with anything
]
""")
assertComments(Seq(" goes with 0"), conf7, "array", 0)
assertComments(Seq(" goes with 1"), conf7, "array", 1)
assertComments(Seq(" with 2", " goes with 2"), conf7, "array", 2)
// properties-like syntax
val conf8 = parseConfig("""
# ignored comment
# x.y comment
x.y = 10
# x.z comment
x.z = 11
# x.a comment
x.a = 12
# a.b comment
a.b = 14
a.c = 15
# ignored comment
""")
assertComments(Seq(" x.y comment"), conf8, "x.y")
assertComments(Seq(" x.z comment"), conf8, "x.z")
assertComments(Seq(" x.a comment"), conf8, "x.a")
assertComments(Seq(" a.b comment"), conf8, "a.b")
assertComments(Seq(), conf8, "a.c")
// here we're concerned that comments apply only to leaf
// nodes, not to parent objects.
assertComments(Seq(), conf8, "x")
assertComments(Seq(), conf8, "a")
}
}

View File

@ -389,6 +389,7 @@ abstract trait TestUtils {
def tokenInt(i: Int) = Tokens.newInt(fakeOrigin(), i, null)
def tokenLong(l: Long) = Tokens.newLong(fakeOrigin(), l, null)
def tokenLine(line: Int) = Tokens.newLine(fakeOrigin.setLineNumber(line))
def tokenComment(text: String) = Tokens.newComment(fakeOrigin(), text)
private def tokenMaybeOptionalSubstitution(optional: Boolean, expression: Token*) = {
val l = new java.util.ArrayList[Token]

View File

@ -108,7 +108,7 @@ class TokenizerTest extends TestUtils {
tokenizerTest(List(tokenUnquoted("a/b/c/")), "a/b/c/")
tokenizerTest(List(tokenUnquoted("/")), "/")
tokenizerTest(List(tokenUnquoted("/"), tokenUnquoted(" "), tokenUnquoted("/")), "/ /")
tokenizerTest(Nil, "//")
tokenizerTest(List(tokenComment("")), "//")
}
@Test
@ -205,15 +205,18 @@ class TokenizerTest extends TestUtils {
}
@Test
def commentsIgnoredInVariousContext() {
def commentsHandledInVariousContexts() {
tokenizerTest(List(tokenString("//bar")), "\"//bar\"")
tokenizerTest(List(tokenString("#bar")), "\"#bar\"")
tokenizerTest(List(tokenUnquoted("bar")), "bar//comment")
tokenizerTest(List(tokenUnquoted("bar")), "bar#comment")
tokenizerTest(List(tokenInt(10)), "10//comment")
tokenizerTest(List(tokenInt(10)), "10#comment")
tokenizerTest(List(tokenDouble(3.14)), "3.14//comment")
tokenizerTest(List(tokenDouble(3.14)), "3.14#comment")
tokenizerTest(List(tokenUnquoted("bar"), tokenComment("comment")), "bar//comment")
tokenizerTest(List(tokenUnquoted("bar"), tokenComment("comment")), "bar#comment")
tokenizerTest(List(tokenInt(10), tokenComment("comment")), "10//comment")
tokenizerTest(List(tokenInt(10), tokenComment("comment")), "10#comment")
tokenizerTest(List(tokenDouble(3.14), tokenComment("comment")), "3.14//comment")
tokenizerTest(List(tokenDouble(3.14), tokenComment("comment")), "3.14#comment")
// be sure we keep the newline
tokenizerTest(List(tokenInt(10), tokenComment("comment"), tokenLine(1), tokenInt(12)), "10//comment\n12")
tokenizerTest(List(tokenInt(10), tokenComment("comment"), tokenLine(1), tokenInt(12)), "10#comment\n12")
}
@Test