This commit is contained in:
Stephan Schroevers
2023-04-23 16:00:02 +02:00
parent 87eec476ef
commit ed4679ab09
3 changed files with 363 additions and 373 deletions

View File

@@ -1,6 +1,5 @@
package tech.picnic.errorprone.openai;
import java.io.IOException;
import java.io.PrintStream;
import java.nio.file.Path;
@@ -30,10 +29,10 @@ public final class AiCoder {
// XXX: Drop the `IOException` and properly handle it.
@Command(
name = "analyze-build-output",
name = "process-build-output",
mixinStandardHelpOptions = true,
description = "Attempts to resolve issues extracted from build output.")
void analyzeBuildOutput(
void processBuildOutput(
@Option(
names = {"-a", "--auto-fix"},
description = "Submit all issues to OpenAI and accept the results.")
@@ -56,7 +55,7 @@ public final class AiCoder {
+ buildOutputSource.buildOutputFile);
}
InteractiveShell.run(openAi, buildOutputSource.buildOutputFile.orElseThrow());
InteractiveBuildOutputProcessor.run(openAi, buildOutputSource.buildOutputFile.orElseThrow());
}
public static void main(String... args) {

View File

@@ -0,0 +1,360 @@
package tech.picnic.errorprone.openai;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Comparator.comparingInt;
import static java.util.stream.Collectors.joining;
import static org.fusesource.jansi.Ansi.ansi;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Multimaps;
import com.google.common.collect.Streams;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.UncheckedIOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.IntStream;
import javax.annotation.concurrent.NotThreadSafe;
import org.fusesource.jansi.Ansi;
import org.jline.console.SystemRegistry;
import org.jline.console.impl.SystemRegistryImpl;
import org.jline.keymap.KeyMap;
import org.jline.reader.EndOfFileException;
import org.jline.reader.LineReader;
import org.jline.reader.LineReaderBuilder;
import org.jline.reader.MaskingCallback;
import org.jline.reader.Parser;
import org.jline.reader.Reference;
import org.jline.reader.UserInterruptException;
import org.jline.reader.impl.DefaultParser;
import org.jline.terminal.Terminal;
import org.jline.terminal.TerminalBuilder;
import org.jline.widget.TailTipWidgets;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.HelpCommand;
import picocli.CommandLine.Parameters;
import picocli.shell.jline3.PicocliCommands;
import picocli.shell.jline3.PicocliCommands.PicocliCommandsFactory;
import tech.picnic.errorprone.openai.IssueExtractor.Issue;
// XXX: Review whether to enable a *subset* of JLine's built-ins. See
// https://github.com/remkop/picocli/tree/main/picocli-shell-jline3#jline-316-and-picocli-44-example.
// XXX: Consider utilizing `less` for paging. See
// https://github.com/jline/jline3/wiki/Nano-and-Less-Customization.
// XXX: Should we even support those `files.isEmpty()` cases?
// XXX: Allow issues to be dropped?
// XXX: Mark files modified/track modification count?
// XXX: List full diff over multiple rounds?
// XXX: Allow submission of a custom instruction.
@Command(name = "")
@NotThreadSafe
final class InteractiveBuildOutputProcessor {
private final OpenAi openAi;
private final PrintWriter out;
private final ImmutableList<FileIssues> files;
private int currentIndex = 0;
InteractiveBuildOutputProcessor(
OpenAi openAi, PrintWriter output, ImmutableSet<Issue<Path>> issues) {
this.openAi = openAi;
this.out = output;
this.files =
Multimaps.asMap(Multimaps.index(issues, Issue::file)).values().stream()
.map(fileIssues -> new FileIssues(ImmutableList.copyOf(fileIssues)))
.collect(toImmutableList());
}
// XXX: Drop the `IOException` and properly handle it.
public static void run(OpenAi openAi, Path buildOutputFile) throws IOException {
// XXX: Allow the path to be specified.
IssueExtractor<Path> issueExtractor =
new PathResolvingIssueExtractor(
new PathFinder(FileSystems.getDefault(), Path.of("")),
new SelectFirstIssueExtractor<>(
ImmutableSet.of(
new MavenCheckstyleIssueExtractor(), new PlexusCompilerIssueExtractor())));
// XXX: Force a file or command to be passed. Can be stdin in non-interactive mode.
ImmutableSet<Issue<Path>> issues =
LogLineExtractor.mavenErrorAndWarningExtractor()
.extract(Files.newInputStream(buildOutputFile))
.stream()
.flatMap(issueExtractor::extract)
.collect(toImmutableSet());
try (Terminal terminal = TerminalBuilder.terminal()) {
InteractiveBuildOutputProcessor processor =
new InteractiveBuildOutputProcessor(openAi, terminal.writer(), issues);
PicocliCommandsFactory factory = new PicocliCommandsFactory();
factory.setTerminal(terminal);
CommandLine commandLine = new CommandLine(processor, factory);
PicocliCommands commands = new PicocliCommands(commandLine);
Parser parser = new DefaultParser();
SystemRegistry systemRegistry =
new SystemRegistryImpl(parser, terminal, () -> Path.of("").toAbsolutePath(), null);
systemRegistry.setCommandRegistries(commands);
systemRegistry.register("help", commands);
LineReader reader =
LineReaderBuilder.builder()
.terminal(terminal)
.completer(systemRegistry.completer())
.parser(parser)
.build();
new TailTipWidgets(
reader, systemRegistry::commandDescription, 5, TailTipWidgets.TipType.COMPLETER)
.enable();
reader.getKeyMaps().get("main").bind(new Reference("tailtip-toggle"), KeyMap.ctrl('t'));
processor.issues();
while (true) {
try {
systemRegistry.cleanUp();
systemRegistry.execute(
reader.readLine(processor.prompt(), null, (MaskingCallback) null, null));
} catch (UserInterruptException e) {
/* User pressed Ctrl+C. */
} catch (EndOfFileException e) {
/* User pressed Ctrl+D. */
return;
} catch (Exception e) {
systemRegistry.trace(e);
}
}
} catch (IOException e) {
throw new UncheckedIOException("Failed to create terminal", e);
}
}
@Command(aliases = "i", subcommands = HelpCommand.class, description = "List issues.")
void issues() {
if (files.isEmpty()) {
out.println(ansi().fgRed().a("No issues.").reset());
return;
}
renderIssueDetails(files.get(currentIndex));
}
// XXX: Review `throws` clause.
// XXX: Allow to submit a custom instruction.
@Command(aliases = "s", subcommands = HelpCommand.class, description = "Submit issues to OpenAI.")
void submit(
@Parameters(description = "The subset of issues to submit (default: all)") @Nullable
Set<Integer> issueNumbers)
throws IOException {
if (files.isEmpty()) {
out.println(ansi().fgRed().a("No issues.").reset());
return;
}
FileIssues fileIssues = files.get(currentIndex);
if (issueNumbers != null) {
for (int i : issueNumbers) {
if (i <= 0 || i > fileIssues.issues().size()) {
out.println(ansi().fgRed().a("Invalid issue number: " + i).reset());
return;
}
}
}
ImmutableList<Issue<Path>> allIssues = fileIssues.issues();
ImmutableList<Issue<Path>> selectedIssues =
issueNumbers == null
? allIssues
: issueNumbers.stream().map(n -> allIssues.get(n - 1)).collect(toImmutableList());
// XXX: Use `ansi()` and a separate thread to show a spinner.
out.println("Submitting issue(s) OpenAI...");
String originalCode = Files.readString(fileIssues.file());
String instruction =
Streams.mapWithIndex(
selectedIssues.stream(),
(description, index) -> String.format("%s. %s", index + 1, description))
.collect(joining("\n", "Resolve the following issues:\n", "\n"));
String result = openAi.requestEdit(originalCode, instruction);
// XXX: Here, store the result.
Diffs.printUnifiedDiff(originalCode, result, fileIssues.relativeFile(), out);
}
@Command(
aliases = "a",
subcommands = HelpCommand.class,
description = "Apply the changes suggested by OpenAI")
void apply() {
// XXX: Implement.
// Verify that there's a proposal.
// XXX: Apply the result only if it applies cleanly.
}
@Command(aliases = "n", subcommands = HelpCommand.class, description = "Move to the next issue.")
void next() {
if (currentIndex < files.size() - 1) {
currentIndex++;
issues();
} else {
out.println("No next issue.");
}
}
@Command(
aliases = "p",
subcommands = HelpCommand.class,
description = "Move to the previous issue.")
void previous() {
if (currentIndex > 0) {
currentIndex--;
issues();
} else {
out.println("No previous issue.");
}
}
String prompt() {
if (files.isEmpty()) {
return ansi().fgRed().a("No issues").reset().a('>').toString();
}
return ansi()
.fgCyan()
.a(files.get(currentIndex).relativeFile())
.reset()
.a(" (")
.bold()
.a(currentIndex + 1)
.a('/')
.a(files.size())
.boldOff()
.a(")>")
.toString();
}
private void renderIssueDetails(FileIssues fileIssues) {
out.println(ansi().a("Issues for ").fgCyan().a(fileIssues.relativeFile()).reset().a(':'));
renderIssueContext(fileIssues);
renderIssues(fileIssues);
// XXX: Here, also list the currently suggested patch, if already generated.
// (...and not yet submitted?)
}
private void renderIssueContext(FileIssues fileIssues) {
ImmutableMap<Integer, ImmutableSet<Integer>> issueLines =
fileIssues.issues().stream()
.filter(issue -> issue.line().isPresent())
.collect(
toImmutableMap(
issue -> issue.line().getAsInt(),
issue ->
issue.column().isPresent()
? ImmutableSet.of(issue.column().getAsInt())
: ImmutableSet.of(),
(a, b) -> ImmutableSet.<Integer>builder().addAll(a).addAll(b).build()));
// XXX: Make context configurable.
// XXX: This would be nicer with a `RangeSet`, but then we'd hit
// https://github.com/google/guava/issues/3033.
ImmutableSet<Integer> ranges =
issueLines.keySet().stream()
.flatMap(line -> IntStream.range(line - 3, line + 4).boxed())
.collect(toImmutableSet());
boolean printedCode = false;
try {
List<String> lines = Files.readAllLines(fileIssues.file(), UTF_8);
for (int i = 1; i <= lines.size(); i++) {
int salience =
(ranges.contains(i - 1) ? 1 : 0)
+ (ranges.contains(i) ? 1 : 0)
+ (ranges.contains(i + 1) ? 1 : 0);
if (salience > 1) {
String line = lines.get(i - 1);
out.print(ansi().fgYellow().a(String.format(Locale.ROOT, "%4d: ", i)).reset());
out.println(
issueLines.containsKey(i) ? highlightIssueLine(line, issueLines.get(i)) : line);
printedCode = true;
} else if (salience > 0 && printedCode) {
out.println(ansi().fgBlue().a(".....").reset());
printedCode = false;
}
}
} catch (IOException e) {
// XXX: Review.
throw new UncheckedIOException("Failed to read file", e);
}
}
private static Ansi highlightIssueLine(String line, ImmutableSet<Integer> positions) {
Ansi ansi = ansi().fgRed();
for (int i = 0; i < line.length(); i++) {
if (positions.contains(i + 1)) {
ansi.bold().a(line.charAt(i)).boldOff();
} else {
ansi.a(line.charAt(i));
}
}
return ansi.reset();
}
private void renderIssues(FileIssues fileIssues) {
ImmutableList<Issue<Path>> issues = fileIssues.issues();
for (int i = 0; i < issues.size(); i++) {
out.println(
ansi()
.fgBlue()
.format(String.format(Locale.ROOT, "%4d. ", i + 1))
.reset()
.a(issues.get(i).description()));
}
}
// XXX: Expose an `ImmutableSet` instead?
record FileIssues(Path file, ImmutableList<Issue<Path>> issues) {
FileIssues(ImmutableList<Issue<Path>> issues) {
this(
issues.stream()
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("No issues provided"))
.file(),
issues);
}
FileIssues(Path file, ImmutableList<Issue<Path>> issues) {
this.file = file;
this.issues =
ImmutableList.sortedCopyOf(
comparingInt((Issue<Path> issue) -> issue.line().orElse(-1))
.thenComparingInt(issue -> issue.column().orElse(-1)),
issues);
checkArgument(!issues.isEmpty(), "No issues provided");
checkArgument(
issues.stream().allMatch(issue -> issue.file().equals(file)),
"Issues must all reference the same file");
}
Path relativeFile() {
return file.getFileSystem().getPath("").toAbsolutePath().relativize(file);
}
}
}

View File

@@ -1,369 +0,0 @@
package tech.picnic.errorprone.openai;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Comparator.comparingInt;
import static java.util.stream.Collectors.joining;
import static org.fusesource.jansi.Ansi.ansi;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Multimaps;
import com.google.common.collect.Streams;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.UncheckedIOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.IntStream;
import javax.annotation.concurrent.NotThreadSafe;
import org.fusesource.jansi.Ansi;
import org.jline.console.SystemRegistry;
import org.jline.console.impl.SystemRegistryImpl;
import org.jline.keymap.KeyMap;
import org.jline.reader.EndOfFileException;
import org.jline.reader.LineReader;
import org.jline.reader.LineReaderBuilder;
import org.jline.reader.MaskingCallback;
import org.jline.reader.Parser;
import org.jline.reader.Reference;
import org.jline.reader.UserInterruptException;
import org.jline.reader.impl.DefaultParser;
import org.jline.terminal.Terminal;
import org.jline.terminal.TerminalBuilder;
import org.jline.widget.TailTipWidgets;
import org.jspecify.annotations.Nullable;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.HelpCommand;
import picocli.CommandLine.Parameters;
import picocli.shell.jline3.PicocliCommands;
import picocli.shell.jline3.PicocliCommands.PicocliCommandsFactory;
import tech.picnic.errorprone.openai.IssueExtractor.Issue;
// XXX: Review whether to enable a *subset* of JLine's built-ins. See
// https://github.com/remkop/picocli/tree/main/picocli-shell-jline3#jline-316-and-picocli-44-example.
// XXX: Consider utilizing `less` for paging. See
// https://github.com/jline/jline3/wiki/Nano-and-Less-Customization.
public final class InteractiveShell {
// XXX: Drop the `IOException` and properly handle it.
public static void run(OpenAi openAi, Path buildOutputFile) throws IOException {
// XXX: Allow the path to be specified.
IssueExtractor<Path> issueExtractor =
new PathResolvingIssueExtractor(
new PathFinder(FileSystems.getDefault(), Path.of("")),
new SelectFirstIssueExtractor<>(
ImmutableSet.of(
new MavenCheckstyleIssueExtractor(), new PlexusCompilerIssueExtractor())));
// XXX: Force a file or command to be passed. Can be stdin in non-interactive mode.
ImmutableSet<Issue<Path>> issues =
LogLineExtractor.mavenErrorAndWarningExtractor()
.extract(Files.newInputStream(buildOutputFile))
.stream()
.flatMap(issueExtractor::extract)
.collect(toImmutableSet());
try (Terminal terminal = TerminalBuilder.terminal()) {
IssueResolutionController issueResolutionController =
new IssueResolutionController(openAi, terminal.writer(), issues);
PicocliCommandsFactory factory = new PicocliCommandsFactory();
factory.setTerminal(terminal);
CommandLine commandLine = new CommandLine(issueResolutionController, factory);
PicocliCommands commands = new PicocliCommands(commandLine);
Parser parser = new DefaultParser();
SystemRegistry systemRegistry =
new SystemRegistryImpl(parser, terminal, () -> Path.of("").toAbsolutePath(), null);
systemRegistry.setCommandRegistries(commands);
systemRegistry.register("help", commands);
LineReader reader =
LineReaderBuilder.builder()
.terminal(terminal)
.completer(systemRegistry.completer())
.parser(parser)
.build();
new TailTipWidgets(
reader, systemRegistry::commandDescription, 5, TailTipWidgets.TipType.COMPLETER)
.enable();
reader.getKeyMaps().get("main").bind(new Reference("tailtip-toggle"), KeyMap.ctrl('t'));
issueResolutionController.issues();
while (true) {
try {
systemRegistry.cleanUp();
systemRegistry.execute(
reader.readLine(
issueResolutionController.prompt(), null, (MaskingCallback) null, null));
} catch (UserInterruptException e) {
/* User pressed Ctrl+C. */
} catch (EndOfFileException e) {
/* User pressed Ctrl+D. */
return;
} catch (Exception e) {
systemRegistry.trace(e);
}
}
} catch (IOException e) {
throw new UncheckedIOException("Failed to create terminal", e);
}
}
// XXX: Should we even support those `files.isEmpty()` cases?
// XXX: Allow issues to be dropped?
// XXX: Mark files modified/track modification count?
// XXX: List full diff over multiple rounds?
// XXX: Allow submission of a custom instruction.
// XXX: Merge with top-level class.
@Command(name = "")
@NotThreadSafe
static final class IssueResolutionController {
private final OpenAi openAi;
private final PrintWriter out;
private final ImmutableList<FileIssues> files;
private int currentIndex = 0;
IssueResolutionController(OpenAi openAi, PrintWriter output, ImmutableSet<Issue<Path>> issues) {
this.openAi = openAi;
this.out = output;
this.files =
Multimaps.asMap(Multimaps.index(issues, Issue::file)).values().stream()
.map(fileIssues -> new FileIssues(ImmutableList.copyOf(fileIssues)))
.collect(toImmutableList());
}
@Command(aliases = "i", subcommands = HelpCommand.class, description = "List issues.")
void issues() {
if (files.isEmpty()) {
out.println(ansi().fgRed().a("No issues.").reset());
return;
}
renderIssueDetails(files.get(currentIndex));
}
// XXX: Review `throws` clause.
// XXX: Allow to submit a custom instruction.
@Command(
aliases = "s",
subcommands = HelpCommand.class,
description = "Submit issues to OpenAI.")
void submit(
@Parameters(description = "The subset of issues to submit (default: all)") @Nullable
Set<Integer> issueNumbers)
throws IOException {
if (files.isEmpty()) {
out.println(ansi().fgRed().a("No issues.").reset());
return;
}
FileIssues fileIssues = files.get(currentIndex);
if (issueNumbers != null) {
for (int i : issueNumbers) {
if (i <= 0 || i > fileIssues.issues().size()) {
out.println(ansi().fgRed().a("Invalid issue number: " + i).reset());
return;
}
}
}
ImmutableList<Issue<Path>> allIssues = fileIssues.issues();
ImmutableList<Issue<Path>> selectedIssues =
issueNumbers == null
? allIssues
: issueNumbers.stream().map(n -> allIssues.get(n - 1)).collect(toImmutableList());
// XXX: Use `ansi()` and a separate thread to show a spinner.
out.println("Submitting issue(s) OpenAI...");
String originalCode = Files.readString(fileIssues.file());
String instruction =
Streams.mapWithIndex(
selectedIssues.stream(),
(description, index) -> String.format("%s. %s", index + 1, description))
.collect(joining("\n", "Resolve the following issues:\n", "\n"));
String result = openAi.requestEdit(originalCode, instruction);
// XXX: Here, store the result.
Diffs.printUnifiedDiff(originalCode, result, fileIssues.relativeFile(), out);
}
@Command(
aliases = "a",
subcommands = HelpCommand.class,
description = "Apply the changes suggested by OpenAI")
void apply() {
// XXX: Implement.
// Verify that there's a proposal.
// XXX: Apply the result only if it applies cleanly.
}
@Command(
aliases = "n",
subcommands = HelpCommand.class,
description = "Move to the next issue.")
void next() {
if (currentIndex < files.size() - 1) {
currentIndex++;
issues();
} else {
out.println("No next issue.");
}
}
@Command(
aliases = "p",
subcommands = HelpCommand.class,
description = "Move to the previous issue.")
void previous() {
if (currentIndex > 0) {
currentIndex--;
issues();
} else {
out.println("No previous issue.");
}
}
String prompt() {
if (files.isEmpty()) {
return ansi().fgRed().a("No issues").reset().a('>').toString();
}
return ansi()
.fgCyan()
.a(files.get(currentIndex).relativeFile())
.reset()
.a(" (")
.bold()
.a(currentIndex + 1)
.a('/')
.a(files.size())
.boldOff()
.a(")>")
.toString();
}
private void renderIssueDetails(FileIssues fileIssues) {
out.println(ansi().a("Issues for ").fgCyan().a(fileIssues.relativeFile()).reset().a(':'));
renderIssueContext(fileIssues);
renderIssues(fileIssues);
// XXX: Here, also list the currently suggested patch, if already generated.
// (...and not yet submitted?)
}
private void renderIssueContext(FileIssues fileIssues) {
ImmutableMap<Integer, ImmutableSet<Integer>> issueLines =
fileIssues.issues().stream()
.filter(issue -> issue.line().isPresent())
.collect(
toImmutableMap(
issue -> issue.line().getAsInt(),
issue ->
issue.column().isPresent()
? ImmutableSet.of(issue.column().getAsInt())
: ImmutableSet.of(),
(a, b) -> ImmutableSet.<Integer>builder().addAll(a).addAll(b).build()));
// XXX: Make context configurable.
// XXX: This would be nicer with a `RangeSet`, but then we'd hit
// https://github.com/google/guava/issues/3033.
ImmutableSet<Integer> ranges =
issueLines.keySet().stream()
.flatMap(line -> IntStream.range(line - 3, line + 4).boxed())
.collect(toImmutableSet());
boolean printedCode = false;
try {
List<String> lines = Files.readAllLines(fileIssues.file(), UTF_8);
for (int i = 1; i <= lines.size(); i++) {
int salience =
(ranges.contains(i - 1) ? 1 : 0)
+ (ranges.contains(i) ? 1 : 0)
+ (ranges.contains(i + 1) ? 1 : 0);
if (salience > 1) {
String line = lines.get(i - 1);
out.print(ansi().fgYellow().a(String.format(Locale.ROOT, "%4d: ", i)).reset());
out.println(
issueLines.containsKey(i) ? highlightIssueLine(line, issueLines.get(i)) : line);
printedCode = true;
} else if (salience > 0 && printedCode) {
out.println(ansi().fgBlue().a(".....").reset());
printedCode = false;
}
}
} catch (IOException e) {
// XXX: Review.
throw new UncheckedIOException("Failed to read file", e);
}
}
private static Ansi highlightIssueLine(String line, ImmutableSet<Integer> positions) {
Ansi ansi = ansi().fgRed();
for (int i = 0; i < line.length(); i++) {
if (positions.contains(i + 1)) {
ansi.bold().a(line.charAt(i)).boldOff();
} else {
ansi.a(line.charAt(i));
}
}
return ansi.reset();
}
private void renderIssues(FileIssues fileIssues) {
ImmutableList<Issue<Path>> issues = fileIssues.issues();
for (int i = 0; i < issues.size(); i++) {
out.println(
ansi()
.fgBlue()
.format(String.format(Locale.ROOT, "%4d. ", i + 1))
.reset()
.a(issues.get(i).description()));
}
}
// XXX: Expose an `ImmutableSet` instead?
record FileIssues(Path file, ImmutableList<Issue<Path>> issues) {
FileIssues(ImmutableList<Issue<Path>> issues) {
this(
issues.stream()
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("No issues provided"))
.file(),
issues);
}
FileIssues(Path file, ImmutableList<Issue<Path>> issues) {
this.file = file;
this.issues =
ImmutableList.sortedCopyOf(
comparingInt((Issue<Path> issue) -> issue.line().orElse(-1))
.thenComparingInt(issue -> issue.column().orElse(-1)),
issues);
checkArgument(!issues.isEmpty(), "No issues provided");
checkArgument(
issues.stream().allMatch(issue -> issue.file().equals(file)),
"Issues must all reference the same file");
}
Path relativeFile() {
return file.getFileSystem().getPath("").toAbsolutePath().relativize(file);
}
}
}
}