LabelFile.java

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
Location : parse
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:anEmptyFileHasNoLabels()]
changed conditional boundary → KILLED

2.2
Location : parse
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:anEmptyFileHasNoLabels()]
negated conditional → KILLED

43

1.1
Location : parse
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:aNameContainingUnicodeWhitespaceGetsANumberedError()]
Replaced integer addition with subtraction → KILLED

46

1.1
Location : parse
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:aNameContainingUnicodeWhitespaceGetsANumberedError()]
negated conditional → KILLED

50

1.1
Location : parse
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:aNameContainingUnicodeWhitespaceGetsANumberedError()]
negated conditional → KILLED

55

1.1
Location : parse
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parsesSingleBytesAndRanges()]
replaced return value with Collections.emptyList for com/rox/debug/labels/LabelFile::parse → KILLED

66

1.1
Location : parseLabel
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
negated conditional → KILLED

69

1.1
Location : parseLabel
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
replaced return value with null for com/rox/debug/labels/LabelFile::parseLabel → KILLED

76

1.1
Location : format
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:formatsSingleBytesAndRangesAsPaddedUppercaseHex()]
negated conditional → KILLED

81

1.1
Location : format
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:formatsSingleBytesAndRangesAsPaddedUppercaseHex()]
replaced return value with Collections.emptyList for com/rox/debug/labels/LabelFile::format → KILLED

86

1.1
Location : read
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:readingAFileThatDoesNotExistYetGivesNoLabels(java.nio.file.Path)]
negated conditional → KILLED

89

1.1
Location : read
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:aNameNeverChangesOnTheWayThroughTheFile(java.nio.file.Path)]
replaced return value with Collections.emptyList for com/rox/debug/labels/LabelFile::read → KILLED

114

1.1
Location : stripComment
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
replaced return value with "" for com/rox/debug/labels/LabelFile::stripComment → KILLED

2.2
Location : stripComment
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelRejectsNothingOrNonsenseAsLine1()]
negated conditional → KILLED

3.3
Location : stripComment
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:ignoresBlankLinesCommentsAndExtraSpacing()]
changed conditional boundary → KILLED

122

1.1
Location : parseLabel
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
negated conditional → KILLED

126

1.1
Location : parseLabel
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
negated conditional → KILLED

2.2
Location : parseLabel
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
changed conditional boundary → KILLED

130

1.1
Location : parseLabel
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
negated conditional → KILLED

131

1.1
Location : parseLabel
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:addressesAreHexWithOrWithoutDollarInEitherCase()]
changed conditional boundary → KILLED

2.2
Location : parseLabel
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
negated conditional → KILLED

136

1.1
Location : parseLabel
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
replaced return value with null for com/rox/debug/labels/LabelFile::parseLabel → KILLED

140

1.1
Location : parseAddress
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
negated conditional → KILLED

142

1.1
Location : parseAddress
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
changed conditional boundary → KILLED

2.2
Location : parseAddress
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
negated conditional → KILLED

3.3
Location : parseAddress
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
negated conditional → KILLED

143

1.1
Location : parseAddress
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
negated conditional → KILLED

2.2
Location : lambda$parseAddress$0
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
negated conditional → KILLED

3.3
Location : lambda$parseAddress$0
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[test-template:rejectsBadLinesWithTheLineNumberAndReason(java.lang.String, java.lang.String)]/[test-template-invocation:#4]
replaced boolean return with true for com/rox/debug/labels/LabelFile::lambda$parseAddress$0 → KILLED

4.4
Location : lambda$parseAddress$0
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
changed conditional boundary → KILLED

146

1.1
Location : parseAddress
Killed by : com.rox.debug.labels.LabelFileTest.[engine:junit-jupiter]/[class:com.rox.debug.labels.LabelFileTest]/[method:parseLabelAcceptsOneLabelInTheFilesOwnSyntax()]
replaced int return with 0 for com/rox/debug/labels/LabelFile::parseAddress → KILLED

Active mutators

Tests examined


Report generated by PIT 1.22.1