| 1 | package com.rox.debug.labels; | |
| 2 | ||
| 3 | import java.io.IOException; | |
| 4 | import java.nio.charset.StandardCharsets; | |
| 5 | import java.nio.file.Files; | |
| 6 | import java.nio.file.Path; | |
| 7 | import java.nio.file.StandardCopyOption; | |
| 8 | import java.util.ArrayList; | |
| 9 | import java.util.HashSet; | |
| 10 | import java.util.List; | |
| 11 | import java.util.Set; | |
| 12 | ||
| 13 | /** | |
| 14 | * A ROM's memory labels as plain, hand-editable text - one label per line: | |
| 15 | * <pre> | |
| 16 | * # Zelda | |
| 17 | * $066D rupees | |
| 18 | * $0670-$0671 key_count # inline comments are fine too | |
| 19 | * </pre> | |
| 20 | * Addresses are hex, with or without a leading {@code $}; a range is inclusive at both ends. Blank lines | |
| 21 | * and {@code #} comments are ignored - and not kept when the file is {@link #write written} back. | |
| 22 | */ | |
| 23 | public final class LabelFile { | |
| 24 | private static final String COMMENT = "#"; | |
| 25 | private static final String HEX_PREFIX = "$"; | |
| 26 | private static final String RANGE_SEPARATOR = "-"; | |
| 27 | private static final String WHITESPACE = "\\p{javaWhitespace}+"; | |
| 28 | private static final int HEX_RADIX = 16; | |
| 29 | private static final int MAX_HEX_DIGITS = 4; | |
| 30 | static final String HEADER = "# EmuRoxLab memory labels: '<address> <name>' or '<start>-<end> <name>', hex addresses." | |
| 31 | + " Comments aren't kept when saved from the Watch window."; | |
| 32 | ||
| 33 | private LabelFile(){ | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * @throws LabelFileException at the first line that isn't a valid, uniquely-named label | |
| 38 | */ | |
| 39 | public static List<MemoryLabel> parse(final List<String> lines) throws LabelFileException { | |
| 40 | final List<MemoryLabel> labels = new ArrayList<>(); | |
| 41 | final Set<String> names = new HashSet<>(); | |
| 42 |
2
1. parse : changed conditional boundary → KILLED 2. parse : negated conditional → KILLED |
for (int i = 0; i < lines.size(); i++){ |
| 43 |
1
1. parse : Replaced integer addition with subtraction → KILLED |
final int lineNumber = i + 1; |
| 44 | //strip(), not trim(): the same Unicode-aware whitespace as MemoryLabel's name rule (see parseLabel) | |
| 45 | final String content = stripComment(lines.get(i)).strip(); | |
| 46 |
1
1. parse : negated conditional → KILLED |
if (content.isEmpty()){ |
| 47 | continue; | |
| 48 | } | |
| 49 | final MemoryLabel label = parseLabel(content, lineNumber); | |
| 50 |
1
1. parse : negated conditional → KILLED |
if (!names.add(label.name())){ |
| 51 | throw new LabelFileException(lineNumber, "'" + label.name() + "' is already used by an earlier label"); | |
| 52 | } | |
| 53 | labels.add(label); | |
| 54 | } | |
| 55 |
1
1. parse : replaced return value with Collections.emptyList for com/rox/debug/labels/LabelFile::parse → KILLED |
return labels; |
| 56 | } | |
| 57 | ||
| 58 | /** | |
| 59 | * One label from text in the file's own syntax, e.g. {@code $066D rupees} - for a UI adding a label, | |
| 60 | * so it accepts exactly what the file does. | |
| 61 | * | |
| 62 | * @throws LabelFileException (reported as line 1) if it isn't a valid label | |
| 63 | */ | |
| 64 | public static MemoryLabel parseLabel(final String text) throws LabelFileException { | |
| 65 | final String content = stripComment(text).strip(); | |
| 66 |
1
1. parseLabel : negated conditional → KILLED |
if (content.isEmpty()){ |
| 67 | throw new LabelFileException(1, "expected '<address> <name>' or '<start>-<end> <name>', got nothing"); | |
| 68 | } | |
| 69 |
1
1. parseLabel : replaced return value with null for com/rox/debug/labels/LabelFile::parseLabel → KILLED |
return parseLabel(content, 1); |
| 70 | } | |
| 71 | ||
| 72 | /** One line per label, in the given order: {@code $066D rupees} or {@code $0670-$0671 key_count}. */ | |
| 73 | public static List<String> format(final List<MemoryLabel> labels){ | |
| 74 | final List<String> lines = new ArrayList<>(labels.size()); | |
| 75 | for (final MemoryLabel label : labels){ | |
| 76 |
1
1. format : negated conditional → KILLED |
final String address = label.length() == 1 |
| 77 | ? String.format("$%04X", label.start()) | |
| 78 | : String.format("$%04X-$%04X", label.start(), label.end()); | |
| 79 | lines.add(address + " " + label.name()); | |
| 80 | } | |
| 81 |
1
1. format : replaced return value with Collections.emptyList for com/rox/debug/labels/LabelFile::format → KILLED |
return lines; |
| 82 | } | |
| 83 | ||
| 84 | /** The labels in {@code path}, or none if it doesn't exist yet. */ | |
| 85 | public static List<MemoryLabel> read(final Path path) throws IOException, LabelFileException { | |
| 86 |
1
1. read : negated conditional → KILLED |
if (!Files.exists(path)){ |
| 87 | return List.of(); | |
| 88 | } | |
| 89 |
1
1. read : replaced return value with Collections.emptyList for com/rox/debug/labels/LabelFile::read → KILLED |
return parse(Files.readAllLines(path, StandardCharsets.UTF_8)); |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * Replaces {@code path}'s contents (creating its folder if needed) - via a temporary file and an | |
| 94 | * atomic move, so a crash mid-save can't leave a half-written labels file. | |
| 95 | */ | |
| 96 | public static void write(final Path path, final List<MemoryLabel> labels) throws IOException { | |
| 97 | Files.createDirectories(path.toAbsolutePath().getParent()); | |
| 98 | final List<String> lines = new ArrayList<>(); | |
| 99 | lines.add(HEADER); | |
| 100 | lines.addAll(format(labels)); | |
| 101 | //a temporary file unique to this write (in the same folder, so the move stays atomic), always cleaned | |
| 102 | //up - two saves at once can't move each other's file away, and a failed write leaves nothing behind | |
| 103 | final Path temporary = Files.createTempFile(path.toAbsolutePath().getParent(), path.getFileName() + "-", ".tmp"); | |
| 104 | try { | |
| 105 | Files.write(temporary, lines, StandardCharsets.UTF_8); | |
| 106 | Files.move(temporary, path, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); | |
| 107 | } finally { | |
| 108 | Files.deleteIfExists(temporary); | |
| 109 | } | |
| 110 | } | |
| 111 | ||
| 112 | private static String stripComment(final String line){ | |
| 113 | final int comment = line.indexOf(COMMENT); | |
| 114 |
3
1. stripComment : replaced return value with "" for com/rox/debug/labels/LabelFile::stripComment → KILLED 2. stripComment : negated conditional → KILLED 3. stripComment : changed conditional boundary → KILLED |
return comment < 0 ? line : line.substring(0, comment); |
| 115 | } | |
| 116 | ||
| 117 | private static MemoryLabel parseLabel(final String content, final int lineNumber) throws LabelFileException { | |
| 118 | //\p{javaWhitespace} is exactly Character.isWhitespace - what MemoryLabel rejects in a name. Plain \s is | |
| 119 | //ASCII-only, so a name containing e.g. an em space would get through to MemoryLabel and fail there | |
| 120 | //without a line number | |
| 121 | final String[] parts = content.split(WHITESPACE); | |
| 122 |
1
1. parseLabel : negated conditional → KILLED |
if (parts.length != 2){ |
| 123 | throw new LabelFileException(lineNumber, "expected '<address> <name>' or '<start>-<end> <name>', got '" + content + "'"); | |
| 124 | } | |
| 125 | final String[] range = parts[0].split(RANGE_SEPARATOR, -1); | |
| 126 |
2
1. parseLabel : negated conditional → KILLED 2. parseLabel : changed conditional boundary → KILLED |
if (range.length > 2){ |
| 127 | throw new LabelFileException(lineNumber, "'" + parts[0] + "' isn't an address or a <start>-<end> range"); | |
| 128 | } | |
| 129 | final int start = parseAddress(range[0], lineNumber); | |
| 130 |
1
1. parseLabel : negated conditional → KILLED |
final int end = range.length == 2 ? parseAddress(range[1], lineNumber) : start; |
| 131 |
2
1. parseLabel : changed conditional boundary → KILLED 2. parseLabel : negated conditional → KILLED |
if (end < start){ |
| 132 | throw new LabelFileException(lineNumber, String.format("range ends ($%04X) before it starts ($%04X)", end, start)); | |
| 133 | } | |
| 134 | //can't throw: the name is one non-empty word (split on MemoryLabel's own definition of whitespace) | |
| 135 | //and both addresses are already checked as in range, with end >= start - everything MemoryLabel rejects | |
| 136 |
1
1. parseLabel : replaced return value with null for com/rox/debug/labels/LabelFile::parseLabel → KILLED |
return MemoryLabel.spanning(parts[1], start, end); |
| 137 | } | |
| 138 | ||
| 139 | private static int parseAddress(final String text, final int lineNumber) throws LabelFileException { | |
| 140 |
1
1. parseAddress : negated conditional → KILLED |
final String digits = text.startsWith(HEX_PREFIX) ? text.substring(HEX_PREFIX.length()) : text; |
| 141 | //checked by hand rather than trusting parseInt, which would also accept a sign ("+1F") | |
| 142 |
3
1. parseAddress : changed conditional boundary → KILLED 2. parseAddress : negated conditional → KILLED 3. parseAddress : negated conditional → KILLED |
if (digits.isEmpty() || digits.length() > MAX_HEX_DIGITS |
| 143 |
4
1. parseAddress : negated conditional → KILLED 2. lambda$parseAddress$0 : negated conditional → KILLED 3. lambda$parseAddress$0 : replaced boolean return with true for com/rox/debug/labels/LabelFile::lambda$parseAddress$0 → KILLED 4. lambda$parseAddress$0 : changed conditional boundary → KILLED |
|| !digits.chars().allMatch(c -> Character.digit(c, HEX_RADIX) >= 0)){ |
| 144 | throw new LabelFileException(lineNumber, "'" + text + "' isn't a hex address ($0000-$FFFF)"); | |
| 145 | } | |
| 146 |
1
1. parseAddress : replaced int return with 0 for com/rox/debug/labels/LabelFile::parseAddress → KILLED |
return Integer.parseInt(digits, HEX_RADIX); |
| 147 | } | |
| 148 | } | |
Mutations | ||
| 42 |
1.1 2.2 |
|
| 43 |
1.1 |
|
| 46 |
1.1 |
|
| 50 |
1.1 |
|
| 55 |
1.1 |
|
| 66 |
1.1 |
|
| 69 |
1.1 |
|
| 76 |
1.1 |
|
| 81 |
1.1 |
|
| 86 |
1.1 |
|
| 89 |
1.1 |
|
| 114 |
1.1 2.2 3.3 |
|
| 122 |
1.1 |
|
| 126 |
1.1 2.2 |
|
| 130 |
1.1 |
|
| 131 |
1.1 2.2 |
|
| 136 |
1.1 |
|
| 140 |
1.1 |
|
| 142 |
1.1 2.2 3.3 |
|
| 143 |
1.1 2.2 3.3 4.4 |
|
| 146 |
1.1 |