diff --git a/core/src/mindustry/logic/LAssembler.java b/core/src/mindustry/logic/LAssembler.java index 1a58d6cc06..63be5c1d12 100644 --- a/core/src/mindustry/logic/LAssembler.java +++ b/core/src/mindustry/logic/LAssembler.java @@ -66,7 +66,7 @@ public class LAssembler{ //string case if(symbol.length() > 1 && symbol.charAt(0) == '\"' && symbol.charAt(symbol.length() - 1) == '\"'){ - return putConst("___" + symbol, symbol.substring(1, symbol.length() - 1)); + return putConst("___" + symbol, unescape(symbol.substring(1, symbol.length() - 1))); } //use a positive invalid number if number might be negative, else use a negative invalid number @@ -81,6 +81,30 @@ public class LAssembler{ } } + /** Decodes \n, \" and \\ escape sequences in a string literal's contents (quotes already stripped). */ + static String unescape(String s){ + if(s.indexOf('\\') == -1) return s; + + StringBuilder out = new StringBuilder(s.length()); + for(int i = 0; i < s.length(); i++){ + char c = s.charAt(i); + if(c == '\\' && i + 1 < s.length()){ + char next = s.charAt(i + 1); + if(next == 'n'){ + out.append('\n'); + i ++; + continue; + }else if(next == '"' || next == '\\'){ + out.append(next); + i ++; + continue; + } + } + out.append(c); + } + return out.toString(); + } + double parseDouble(String symbol){ //parse hex/binary syntax if(symbol.startsWith("0b")) return parseLong(false, symbol, 2, 2, symbol.length()); @@ -150,4 +174,4 @@ public class LAssembler{ return vars.get(name); } -} +} \ No newline at end of file diff --git a/core/src/mindustry/logic/LParser.java b/core/src/mindustry/logic/LParser.java index f4a72ca1c7..3c07574e12 100644 --- a/core/src/mindustry/logic/LParser.java +++ b/core/src/mindustry/logic/LParser.java @@ -44,34 +44,16 @@ public class LParser{ String string(){ int from = pos; int utflen = 0; - //only allocated if an escape sequence is found - StringBuilder escaped = null; while(++pos < chars.length){ char c = chars[pos]; - //handle \n and \" escape sequences (also \\ so a literal backslash can be written) - if(c == '\\' && pos + 1 < chars.length){ - char translated = switch(chars[pos + 1]){ - case 'n' -> '\n'; - case '"' -> '"'; - case '\\' -> '\\'; - default -> 0; - }; - - if(translated != 0){ - if(escaped == null){ - //copy everything read so far (without the surrounding quote) - escaped = new StringBuilder(); - escaped.append(chars, from + 1, pos - from - 1); - } - - escaped.append(translated); - utflen += translated <= 0x7F ? 1 : translated <= 0x7FF ? 2 : 3; - pos ++; //consume the escaped character too - continue; - } - //not a recognized escape sequence; fall through and treat the backslash as a literal character (TODO: isn't this an invalid escape?) + //skip over \n, \" and \\ escape sequences + //this doesn't actually transform the sequences, as that would output invalid characters into Statement fields and break round-trip parsing + if(c == '\\' && pos + 1 < chars.length && (chars[pos + 1] == 'n' || chars[pos + 1] == '"' || chars[pos + 1] == '\\')){ + utflen += 2; + pos ++; //consume the escaped character too + continue; } if(c == '\n'){ @@ -80,8 +62,6 @@ public class LParser{ break; } - if(escaped != null) escaped.append(c); - // See ByteBufferOutput.writeUTF() utflen += c != 0 && c <= 0x7F ? 1 : c <= 0x7FF ? 2 : 3; } @@ -91,10 +71,6 @@ public class LParser{ pos ++; //move past the closing quote - if(escaped != null){ - return "\"" + escaped + "\""; - } - return new String(chars, from, pos - from); } diff --git a/core/src/mindustry/logic/LStatement.java b/core/src/mindustry/logic/LStatement.java index 9e08dbc6cd..5f17caf888 100644 --- a/core/src/mindustry/logic/LStatement.java +++ b/core/src/mindustry/logic/LStatement.java @@ -88,16 +88,26 @@ public abstract class LStatement{ if(value.length() == 0){ return ""; }else if(value.length() == 1){ - if(value.charAt(0) == '"' || value.charAt(0) == ';' || value.charAt(0) == ' '){ + if(value.charAt(0) == '"' || value.charAt(0) == ';' || value.charAt(0) == ' ' || + value.charAt(0) == '\n' || value.charAt(0) == '\t' || value.charAt(0) == '#'){ return "invalid"; } }else{ StringBuilder res = new StringBuilder(value.length()); if(value.charAt(0) == '"' && value.charAt(value.length() - 1) == '"'){ res.append('\"'); - //escape characters that would otherwise break out of the string or corrupt it + //escape characters that would otherwise break out of the string or corrupt it. for(int i = 1; i < value.length() - 1; i++){ char c = value.charAt(i); + //exception: allow escape sequences in strings: \n, \", \\ + if(c == '\\' && i + 1 < value.length() - 1){ + char next = value.charAt(i + 1); + if(next == '"' || next == '\\' || next == 'n'){ + res.append(c).append(next); + i ++; //consumed the escape target too + continue; + } + } switch(c){ case '"' -> res.append("\\\""); case '\\' -> res.append("\\\\"); @@ -107,13 +117,14 @@ public abstract class LStatement{ } res.append('\"'); }else{ - //otherwise, strip out semicolons, spaces and quotes + //otherwise, strip out/replace anything the tokenizer would treat as a delimiter or + //comment start for an unquoted token: semicolons, spaces, quotes, tabs, newlines and '#' for(int i = 0; i < value.length(); i++){ char c = value.charAt(i); res.append(switch(c){ case ';' -> 's'; case '"' -> '\''; - case ' ' -> '_'; + case ' ', '\t', '\n', '#' -> '_'; default -> c; }); } diff --git a/core/src/mindustry/logic/LStatements.java b/core/src/mindustry/logic/LStatements.java index 7acfc6beef..b1fadef1b1 100644 --- a/core/src/mindustry/logic/LStatements.java +++ b/core/src/mindustry/logic/LStatements.java @@ -1040,7 +1040,7 @@ public class LStatements{ if(op != ConditionOp.always) st.field(t, comp0, set0); t.button(b -> { - b.add(st.selectTranslate(op.symbol)); + b.add(selectTranslate(op.symbol)); b.clicked(() -> st.showSelect(b, ConditionOp.all, op, getter)); }, Styles.logict, () -> { }).size(op == ConditionOp.always ? 80f : 48f, 40f).pad(4f).color(t.color); diff --git a/tests/src/test/java/LogicTests.java b/tests/src/test/java/LogicTests.java index 1148d82173..0f8fe1f5a4 100644 --- a/tests/src/test/java/LogicTests.java +++ b/tests/src/test/java/LogicTests.java @@ -134,7 +134,13 @@ public class LogicTests{ "unquoted values get semicolons/spaces/quotes replaced instead of escaped", "hello world;test\"quote", "hello_worldstest'quote" - ) + ), + Arguments.of("a raw newline in an unquoted value gets neutralized like a space", "a\nb\nc", "a_b_c"), + Arguments.of("a raw tab in an unquoted value gets neutralized like a space", "a\tb", "a_b"), + Arguments.of("a raw '#' in an unquoted value gets neutralized so it can't start a comment", "a#b", "a_b"), + Arguments.of("a lone newline character is invalid on its own, same as a lone space", "\n", "invalid"), + Arguments.of("a lone tab character is invalid on its own, same as a lone space", "\t", "invalid"), + Arguments.of("a lone '#' character is invalid on its own, same as a lone space", "#", "invalid") ); } @@ -147,6 +153,10 @@ public class LogicTests{ Object decoded = setFromValue("set result " + sanitized); assertEquals(expectedDecoded, decoded); + + //make sure read/write roundtrips it + var statements = LAssembler.read("set result " + sanitized + "\n", true); + assertEquals("set result " + sanitized + "\n", LAssembler.write(statements)); } static Stream sanitizeRoundTripCases(){ @@ -155,10 +165,40 @@ public class LogicTests{ Arguments.of("bare embedded quote round-trips to a literal quote", "\"a\"b\"", "a\"b"), Arguments.of("literal backslash round-trips to a single backslash", "\"C:\\Users\"", "C:\\Users"), Arguments.of("real embedded newline round-trips to a real newline", "\"line1\nline2\"", "line1\nline2"), + Arguments.of("newline typed as escape turns into real backslash", "\"a\\nb\\nc\"", "a\nb\nc"), + + //check intentional escapes Arguments.of( "bare quote + backslash + newline combined round-trip correctly together", "\"a\\b\"c\nd\"", "a\\b\"c\nd" + ), + Arguments.of( + "typing \\n by hand in a single-line field decodes to a real newline, not literal backslash+n", + "\"a\\nb\\nc\"", + "a\nb\nc" + ), + Arguments.of( + "typing \\\" by hand decodes to a literal embedded quote, not literal backslash+quote", + "\"say \\\"hi\\\"\"", + "say \"hi\"" + ), + //this case is a little unintuitive (two backslashes = one backslash, but so is one with nothing after it?) + //I don't have any better ideas for handling this + Arguments.of( + "typing \\\\ by hand (two backslashes) decodes to a single literal backslash", + "\"a\\\\b\"", + "a\\b" + ), + Arguments.of( + "a lone backslash NOT forming a recognized escape still round-trips as itself, unaffected by the fix", + "\"C:\\Users\"", + "C:\\Users" + ), + Arguments.of( + "a trailing lone backslash right before the closing quote still round-trips as itself", + "\"end\\\"", + "end\\" ) ); }