From 5a286d58e740f1aa5944488c602f5abc1318f6ca Mon Sep 17 00:00:00 2001 From: 2xsaiko Date: Wed, 3 Jun 2020 20:16:10 +0200 Subject: Editor tabs (#238) * Split into modules * Add validation utils from patch-1 branch * Tabs, iteration 1 * Delete RefreshMode * Load initial code asynchronously * Formatting * Don't do anything when close() gets called multiple times * Add scroll pane to editor * Fix getActiveEditor() * Rename components to more descriptive editorScrollPanes * Move ClassHandle and related types out of gui package * Fix tab title bar and other files not updating when changing mappings * Fix compilation errors * Start adding renaming functionality to new panel * Scale validation error marker * Make most user input validation use ValidationContext * Fix line numbers not displaying * Move CodeReader.navigateToToken into PanelEditor * Add close button on tabs * Remove TODO, it's fast enough * Remove JS script action for 2 seconds faster startup * Add comment on why the action is removed * ClassHandle/ClassHandleProvider documentation * Fix language file formatting * Bulk tab closing operations * Fix crash when renaming class and not connected to server * Fix caret jumping to the end of the file when opening * Increase identifier panel size * Make popup menu text translatable * Fix formatting * Fix compilation issues * CovertTextField -> ConvertingTextField * Retain formatting using spaces * Add de_de.json * Better decompilation error handling * Fix some caret related NPEs * Localization * Close editor on classhandle delete & fix onInvalidate not running on the Swing thread * Fix crash when trying to close a tab from onDeleted class handle listener Co-authored-by: Runemoro --- .../cuchaz/enigma/analysis/EntryReference.java | 12 +- .../cuchaz/enigma/classhandle/ClassHandle.java | 108 +++++ .../enigma/classhandle/ClassHandleError.java | 35 ++ .../enigma/classhandle/ClassHandleProvider.java | 445 +++++++++++++++++++++ .../cuchaz/enigma/events/ClassHandleListener.java | 36 ++ .../enigma/source/DecompiledClassSource.java | 157 ++++++++ .../cuchaz/enigma/source/RenamableTokenType.java | 7 + .../enigma/translation/LocalNameGenerator.java | 8 +- .../enigma/translation/mapping/EntryRemapper.java | 28 +- .../translation/mapping/IdentifierValidation.java | 79 ++++ .../translation/mapping/IllegalNameException.java | 39 -- .../translation/mapping/MappingValidator.java | 24 +- .../enigma/translation/mapping/NameValidator.java | 50 --- .../representation/entry/ClassEntry.java | 19 +- .../translation/representation/entry/Entry.java | 15 +- enigma/src/main/java/cuchaz/enigma/utils/I18n.java | 55 ++- .../src/main/java/cuchaz/enigma/utils/Result.java | 108 +++++ .../src/main/java/cuchaz/enigma/utils/Utils.java | 21 + .../cuchaz/enigma/utils/validation/Message.java | 48 +++ .../utils/validation/ParameterizedMessage.java | 45 +++ .../enigma/utils/validation/PrintValidatable.java | 37 ++ .../utils/validation/StandardValidation.java | 34 ++ .../enigma/utils/validation/Validatable.java | 9 + .../enigma/utils/validation/ValidationContext.java | 78 ++++ enigma/src/main/resources/lang/de_de.json | 26 ++ enigma/src/main/resources/lang/en_us.json | 27 +- 26 files changed, 1395 insertions(+), 155 deletions(-) create mode 100644 enigma/src/main/java/cuchaz/enigma/classhandle/ClassHandle.java create mode 100644 enigma/src/main/java/cuchaz/enigma/classhandle/ClassHandleError.java create mode 100644 enigma/src/main/java/cuchaz/enigma/classhandle/ClassHandleProvider.java create mode 100644 enigma/src/main/java/cuchaz/enigma/events/ClassHandleListener.java create mode 100644 enigma/src/main/java/cuchaz/enigma/source/DecompiledClassSource.java create mode 100644 enigma/src/main/java/cuchaz/enigma/source/RenamableTokenType.java create mode 100644 enigma/src/main/java/cuchaz/enigma/translation/mapping/IdentifierValidation.java delete mode 100644 enigma/src/main/java/cuchaz/enigma/translation/mapping/IllegalNameException.java delete mode 100644 enigma/src/main/java/cuchaz/enigma/translation/mapping/NameValidator.java create mode 100644 enigma/src/main/java/cuchaz/enigma/utils/Result.java create mode 100644 enigma/src/main/java/cuchaz/enigma/utils/validation/Message.java create mode 100644 enigma/src/main/java/cuchaz/enigma/utils/validation/ParameterizedMessage.java create mode 100644 enigma/src/main/java/cuchaz/enigma/utils/validation/PrintValidatable.java create mode 100644 enigma/src/main/java/cuchaz/enigma/utils/validation/StandardValidation.java create mode 100644 enigma/src/main/java/cuchaz/enigma/utils/validation/Validatable.java create mode 100644 enigma/src/main/java/cuchaz/enigma/utils/validation/ValidationContext.java create mode 100644 enigma/src/main/resources/lang/de_de.json (limited to 'enigma/src') diff --git a/enigma/src/main/java/cuchaz/enigma/analysis/EntryReference.java b/enigma/src/main/java/cuchaz/enigma/analysis/EntryReference.java index 320f945..9d6bc4e 100644 --- a/enigma/src/main/java/cuchaz/enigma/analysis/EntryReference.java +++ b/enigma/src/main/java/cuchaz/enigma/analysis/EntryReference.java @@ -11,19 +11,19 @@ package cuchaz.enigma.analysis; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + import cuchaz.enigma.translation.Translatable; import cuchaz.enigma.translation.Translator; +import cuchaz.enigma.translation.mapping.EntryMap; import cuchaz.enigma.translation.mapping.EntryMapping; import cuchaz.enigma.translation.mapping.EntryResolver; -import cuchaz.enigma.translation.mapping.EntryMap; import cuchaz.enigma.translation.representation.entry.ClassEntry; import cuchaz.enigma.translation.representation.entry.Entry; import cuchaz.enigma.translation.representation.entry.MethodEntry; -import java.util.Arrays; -import java.util.List; -import java.util.Objects; - public class EntryReference, C extends Entry> implements Translatable { private static final List CONSTRUCTOR_NON_NAMES = Arrays.asList("this", "super", "static"); @@ -100,6 +100,8 @@ public class EntryReference, C extends Entry> implements T } public boolean equals(EntryReference other) { + if (other == null) return false; + // check entry first boolean isEntrySame = entry.equals(other.entry); if (!isEntrySame) { diff --git a/enigma/src/main/java/cuchaz/enigma/classhandle/ClassHandle.java b/enigma/src/main/java/cuchaz/enigma/classhandle/ClassHandle.java new file mode 100644 index 0000000..326197d --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/classhandle/ClassHandle.java @@ -0,0 +1,108 @@ +package cuchaz.enigma.classhandle; + +import java.util.concurrent.CompletableFuture; + +import javax.annotation.Nullable; + +import cuchaz.enigma.events.ClassHandleListener; +import cuchaz.enigma.source.DecompiledClassSource; +import cuchaz.enigma.source.Source; +import cuchaz.enigma.translation.representation.entry.ClassEntry; +import cuchaz.enigma.utils.Result; + +/** + * A handle to a class file. Can be treated similarly to a handle to a file. + * This type allows for accessing decompiled classes and being notified when + * mappings for the class it belongs to changes. + * + * @see ClassHandleProvider + */ +public interface ClassHandle extends AutoCloseable { + + /** + * Gets the reference to this class. This is always obfuscated, for example + * {@code net/minecraft/class_1000}. + * + * @return the obfuscated class reference + * @throws IllegalStateException if the class handle is closed + */ + ClassEntry getRef(); + + /** + * Gets the deobfuscated reference to this class, if any. + * + * @return the deobfuscated reference, or {@code null} if the class is not + * mapped + * @throws IllegalStateException if the class handle is closed + */ + @Nullable + ClassEntry getDeobfRef(); + + /** + * Gets the class source, or the error generated while decompiling the + * class, asynchronously. If this class has already finished decompiling, + * this will return an already completed future. + * + * @return the class source + * @throws IllegalStateException if the class handle is closed + */ + CompletableFuture> getSource(); + + /** + * Gets the class source without any decoration, or the error generated + * while decompiling the class, asynchronously. This is the raw source from + * the decompiler and will not be deobfuscated, and does not contain any + * Javadoc comments added via mappings. If this class has already finished + * decompiling, this will return an already completed future. + * + * @return the uncommented class source + * @throws IllegalStateException if the class handle is closed + * @see ClassHandle#getSource() + */ + CompletableFuture> getUncommentedSource(); + + void invalidate(); + + void invalidateMapped(); + + void invalidateJavadoc(); + + /** + * Adds a listener for this class handle. + * + * @param listener the listener to add + * @see ClassHandleListener + */ + void addListener(ClassHandleListener listener); + + /** + * Removes a previously added listener (with + * {@link ClassHandle#addListener(ClassHandleListener)}) from this class + * handle. + * + * @param listener the listener to remove + */ + void removeListener(ClassHandleListener listener); + + /** + * Copies this class handle. The new class handle points to the same class, + * but is independent from this class handle in every other aspect. + * Specifically, any listeners will not be copied to the new class handle. + * + * @return a copy of this class handle + * @throws IllegalStateException if the class handle is closed + */ + ClassHandle copy(); + + /** + * {@inheritDoc} + * + *

Specifically, for class handles, this means that most methods on the + * handle will throw an exception if called, that the handle will no longer + * receive any events over any added listeners, and the handle will no + * longer be able to be copied. + */ + @Override + void close(); + +} diff --git a/enigma/src/main/java/cuchaz/enigma/classhandle/ClassHandleError.java b/enigma/src/main/java/cuchaz/enigma/classhandle/ClassHandleError.java new file mode 100644 index 0000000..a11b9dc --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/classhandle/ClassHandleError.java @@ -0,0 +1,35 @@ +package cuchaz.enigma.classhandle; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; + +import javax.annotation.Nullable; + +public final class ClassHandleError { + + public final Type type; + public final Throwable cause; + + private ClassHandleError(Type type, Throwable cause) { + this.type = type; + this.cause = cause; + } + + @Nullable + public String getStackTrace() { + if (cause == null) return null; + ByteArrayOutputStream os = new ByteArrayOutputStream(); + PrintStream ps = new PrintStream(os); + cause.printStackTrace(ps); + return os.toString(); + } + + public static ClassHandleError decompile(Throwable cause) { + return new ClassHandleError(Type.DECOMPILE, cause); + } + + public enum Type { + DECOMPILE, + } + +} \ No newline at end of file diff --git a/enigma/src/main/java/cuchaz/enigma/classhandle/ClassHandleProvider.java b/enigma/src/main/java/cuchaz/enigma/classhandle/ClassHandleProvider.java new file mode 100644 index 0000000..2d9b52d --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/classhandle/ClassHandleProvider.java @@ -0,0 +1,445 @@ +package cuchaz.enigma.classhandle; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +import javax.annotation.Nullable; + +import cuchaz.enigma.Enigma; +import cuchaz.enigma.EnigmaProject; +import cuchaz.enigma.bytecode.translators.SourceFixVisitor; +import cuchaz.enigma.events.ClassHandleListener; +import cuchaz.enigma.events.ClassHandleListener.InvalidationType; +import cuchaz.enigma.source.*; +import cuchaz.enigma.translation.representation.entry.ClassEntry; +import cuchaz.enigma.utils.Result; +import org.objectweb.asm.tree.ClassNode; + +import static cuchaz.enigma.utils.Utils.withLock; + +public final class ClassHandleProvider { + + private final EnigmaProject project; + + private final ExecutorService pool = Executors.newWorkStealingPool(); + private DecompilerService ds; + private Decompiler decompiler; + + private final Map handles = new HashMap<>(); + + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + + public ClassHandleProvider(EnigmaProject project, DecompilerService ds) { + this.project = project; + this.ds = ds; + this.decompiler = createDecompiler(); + } + + /** + * Open a class by entry. Schedules decompilation immediately if this is the + * only handle to the class. + * + * @param entry the entry of the class to open + * @return a handle to the class, {@code null} if a class by that name does + * not exist + */ + @Nullable + public ClassHandle openClass(ClassEntry entry) { + if (!project.getJarIndex().getEntryIndex().hasClass(entry)) return null; + + return withLock(lock.writeLock(), () -> { + Entry e = handles.computeIfAbsent(entry, entry1 -> new Entry(this, entry1)); + return e.createHandle(); + }); + } + + /** + * Set the decompiler service to use when decompiling classes. Invalidates + * all currently open classes. + * + *

If the current decompiler service equals the old one, no classes will + * be invalidated. + * + * @param ds the decompiler service to use + */ + public void setDecompilerService(DecompilerService ds) { + if (this.ds.equals(ds)) return; + + this.ds = ds; + this.decompiler = createDecompiler(); + withLock(lock.readLock(), () -> { + handles.values().forEach(Entry::invalidate); + }); + } + + /** + * Gets the current decompiler service in use. + * + * @return the current decompiler service + */ + public DecompilerService getDecompilerService() { + return ds; + } + + private Decompiler createDecompiler() { + return ds.create(name -> { + ClassNode node = project.getClassCache().getClassNode(name); + + if (node == null) { + return null; + } + + ClassNode fixedNode = new ClassNode(); + node.accept(new SourceFixVisitor(Enigma.ASM_VERSION, fixedNode, project.getJarIndex())); + return fixedNode; + }, new SourceSettings(true, true)); + } + + /** + * Invalidates all mappings. This causes all open class handles to be + * re-remapped. + */ + public void invalidateMapped() { + withLock(lock.readLock(), () -> { + handles.values().forEach(Entry::invalidateMapped); + }); + } + + /** + * Invalidates mappings for a single class. Note that this does not + * invalidate any mappings of other classes where this class is used, so + * this should not be used to notify that the mapped name for this class has + * changed. + * + * @param entry the class entry to invalidate + */ + public void invalidateMapped(ClassEntry entry) { + withLock(lock.readLock(), () -> { + Entry e = handles.get(entry); + if (e != null) { + e.invalidateMapped(); + } + }); + } + + /** + * Invalidates javadoc for a single class. This also causes the class to be + * remapped again. + * + * @param entry the class entry to invalidate + */ + public void invalidateJavadoc(ClassEntry entry) { + withLock(lock.readLock(), () -> { + Entry e = handles.get(entry); + if (e != null) { + e.invalidateJavadoc(); + } + }); + } + + private void deleteEntry(Entry entry) { + withLock(lock.writeLock(), () -> { + handles.remove(entry.entry); + }); + } + + /** + * Destroy this class handle provider. The decompiler threads will try to + * shutdown cleanly, and then every open class handle will also be deleted. + * This causes {@link ClassHandleListener#onDeleted(ClassHandle)} to get + * called. + * + *

After this method is called, this class handle provider can no longer + * be used. + */ + public void destroy() { + pool.shutdown(); + try { + pool.awaitTermination(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + withLock(lock.writeLock(), () -> { + handles.values().forEach(Entry::destroy); + handles.clear(); + }); + } + + private static final class Entry { + + private final ClassHandleProvider p; + private final ClassEntry entry; + private ClassEntry deobfRef; + private final List handles = new ArrayList<>(); + private Result uncommentedSource; + private Result source; + + private final List>> waitingUncommentedSources = Collections.synchronizedList(new ArrayList<>()); + private final List>> waitingSources = Collections.synchronizedList(new ArrayList<>()); + + private final AtomicInteger decompileVersion = new AtomicInteger(); + private final AtomicInteger javadocVersion = new AtomicInteger(); + private final AtomicInteger indexVersion = new AtomicInteger(); + private final AtomicInteger mappedVersion = new AtomicInteger(); + + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + + private Entry(ClassHandleProvider p, ClassEntry entry) { + this.p = p; + this.entry = entry; + this.deobfRef = p.project.getMapper().deobfuscate(entry); + invalidate(); + } + + public ClassHandleImpl createHandle() { + ClassHandleImpl handle = new ClassHandleImpl(this); + withLock(lock.writeLock(), () -> { + handles.add(handle); + }); + return handle; + } + + @Nullable + public ClassEntry getDeobfRef() { + return deobfRef; + } + + private void checkDeobfRefForUpdate() { + ClassEntry newDeobf = p.project.getMapper().deobfuscate(entry); + if (!Objects.equals(deobfRef, newDeobf)) { + deobfRef = newDeobf; + // copy the list so we don't call event listener code with the lock active + withLock(lock.readLock(), () -> new ArrayList<>(handles)).forEach(h -> h.onDeobfRefChanged(newDeobf)); + } + } + + public void invalidate() { + checkDeobfRefForUpdate(); + withLock(lock.readLock(), () -> new ArrayList<>(handles)).forEach(h -> h.onInvalidate(InvalidationType.FULL)); + continueMapSource(continueIndexSource(continueInsertJavadoc(decompile()))); + } + + public void invalidateJavadoc() { + checkDeobfRefForUpdate(); + withLock(lock.readLock(), () -> new ArrayList<>(handles)).forEach(h -> h.onInvalidate(InvalidationType.JAVADOC)); + continueMapSource(continueIndexSource(continueInsertJavadoc(CompletableFuture.completedFuture(uncommentedSource)))); + } + + public void invalidateMapped() { + checkDeobfRefForUpdate(); + withLock(lock.readLock(), () -> new ArrayList<>(handles)).forEach(h -> h.onInvalidate(InvalidationType.MAPPINGS)); + continueMapSource(CompletableFuture.completedFuture(source)); + } + + private CompletableFuture> decompile() { + int v = decompileVersion.incrementAndGet(); + return CompletableFuture.supplyAsync(() -> { + if (decompileVersion.get() != v) return null; + + Result _uncommentedSource; + try { + _uncommentedSource = Result.ok(p.decompiler.getSource(entry.getFullName())); + } catch (Throwable e) { + return Result.err(ClassHandleError.decompile(e)); + } + Result uncommentedSource = _uncommentedSource; + Entry.this.uncommentedSource = uncommentedSource; + Entry.this.waitingUncommentedSources.forEach(f -> f.complete(uncommentedSource)); + Entry.this.waitingUncommentedSources.clear(); + withLock(lock.readLock(), () -> new ArrayList<>(handles)).forEach(h -> h.onUncommentedSourceChanged(uncommentedSource)); + return uncommentedSource; + }, p.pool); + } + + private CompletableFuture> continueInsertJavadoc(CompletableFuture> f) { + int v = javadocVersion.incrementAndGet(); + return f.thenApplyAsync(res -> { + if (res == null || javadocVersion.get() != v) return null; + Result jdSource = res.map(s -> s.addJavadocs(p.project.getMapper())); + withLock(lock.readLock(), () -> new ArrayList<>(handles)).forEach(h -> h.onDocsChanged(jdSource)); + return jdSource; + }, p.pool); + } + + private CompletableFuture> continueIndexSource(CompletableFuture> f) { + int v = indexVersion.incrementAndGet(); + return f.thenApplyAsync(res -> { + if (res == null || indexVersion.get() != v) return null; + return res.andThen(jdSource -> { + SourceIndex index = jdSource.index(); + index.resolveReferences(p.project.getMapper().getObfResolver()); + DecompiledClassSource source = new DecompiledClassSource(entry, index); + return Result.ok(source); + }); + }, p.pool); + } + + private void continueMapSource(CompletableFuture> f) { + int v = mappedVersion.incrementAndGet(); + f.thenAcceptAsync(res -> { + if (res == null || mappedVersion.get() != v) return; + res = res.map(source -> { + source.remapSource(p.project, p.project.getMapper().getDeobfuscator()); + return source; + }); + Entry.this.source = res; + Entry.this.waitingSources.forEach(s -> s.complete(source)); + Entry.this.waitingSources.clear(); + withLock(lock.readLock(), () -> new ArrayList<>(handles)).forEach(h -> h.onMappedSourceChanged(source)); + }, p.pool); + } + + public void closeHandle(ClassHandleImpl classHandle) { + classHandle.destroy(); + withLock(lock.writeLock(), () -> { + handles.remove(classHandle); + if (handles.isEmpty()) { + p.deleteEntry(this); + } + }); + } + + public void destroy() { + withLock(lock.writeLock(), () -> { + handles.forEach(ClassHandleImpl::destroy); + handles.clear(); + }); + } + + public CompletableFuture> getUncommentedSourceAsync() { + if (uncommentedSource != null) { + return CompletableFuture.completedFuture(uncommentedSource); + } else { + CompletableFuture> f = new CompletableFuture<>(); + waitingUncommentedSources.add(f); + return f; + } + } + + public CompletableFuture> getSourceAsync() { + if (source != null) { + return CompletableFuture.completedFuture(source); + } else { + CompletableFuture> f = new CompletableFuture<>(); + waitingSources.add(f); + return f; + } + } + } + + private static final class ClassHandleImpl implements ClassHandle { + + private final Entry entry; + + private boolean valid = true; + + private final Set listeners = new HashSet<>(); + + private ClassHandleImpl(Entry entry) { + this.entry = entry; + } + + @Override + public ClassEntry getRef() { + checkValid(); + return entry.entry; + } + + @Nullable + @Override + public ClassEntry getDeobfRef() { + checkValid(); + // cache this? + return entry.getDeobfRef(); + } + + @Override + public CompletableFuture> getSource() { + checkValid(); + return entry.getSourceAsync(); + } + + @Override + public CompletableFuture> getUncommentedSource() { + checkValid(); + return entry.getUncommentedSourceAsync(); + } + + @Override + public void invalidate() { + checkValid(); + this.entry.invalidate(); + } + + @Override + public void invalidateMapped() { + checkValid(); + this.entry.invalidateMapped(); + } + + @Override + public void invalidateJavadoc() { + checkValid(); + this.entry.invalidateJavadoc(); + } + + public void onUncommentedSourceChanged(Result source) { + listeners.forEach(l -> l.onUncommentedSourceChanged(this, source)); + } + + public void onDocsChanged(Result source) { + listeners.forEach(l -> l.onDocsChanged(this, source)); + } + + public void onMappedSourceChanged(Result source) { + listeners.forEach(l -> l.onMappedSourceChanged(this, source)); + } + + public void onInvalidate(InvalidationType t) { + listeners.forEach(l -> l.onInvalidate(this, t)); + } + + public void onDeobfRefChanged(ClassEntry newDeobf) { + listeners.forEach(l -> l.onDeobfRefChanged(this, newDeobf)); + } + + @Override + public void addListener(ClassHandleListener listener) { + listeners.add(listener); + } + + @Override + public void removeListener(ClassHandleListener listener) { + listeners.remove(listener); + } + + @Override + public ClassHandle copy() { + checkValid(); + return entry.createHandle(); + } + + @Override + public void close() { + if (valid) entry.closeHandle(this); + } + + private void checkValid() { + if (!valid) throw new IllegalStateException("Class handle no longer valid"); + } + + public void destroy() { + listeners.forEach(l -> l.onDeleted(this)); + valid = false; + } + + } + +} diff --git a/enigma/src/main/java/cuchaz/enigma/events/ClassHandleListener.java b/enigma/src/main/java/cuchaz/enigma/events/ClassHandleListener.java new file mode 100644 index 0000000..61fea4e --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/events/ClassHandleListener.java @@ -0,0 +1,36 @@ +package cuchaz.enigma.events; + +import cuchaz.enigma.classhandle.ClassHandle; +import cuchaz.enigma.classhandle.ClassHandleError; +import cuchaz.enigma.source.DecompiledClassSource; +import cuchaz.enigma.source.Source; +import cuchaz.enigma.translation.representation.entry.ClassEntry; +import cuchaz.enigma.utils.Result; + +public interface ClassHandleListener { + + default void onDeobfRefChanged(ClassHandle h, ClassEntry deobfRef) { + } + + default void onUncommentedSourceChanged(ClassHandle h, Result res) { + } + + default void onDocsChanged(ClassHandle h, Result res) { + } + + default void onMappedSourceChanged(ClassHandle h, Result res) { + } + + default void onInvalidate(ClassHandle h, InvalidationType t) { + } + + default void onDeleted(ClassHandle h) { + } + + enum InvalidationType { + FULL, + JAVADOC, + MAPPINGS, + } + +} diff --git a/enigma/src/main/java/cuchaz/enigma/source/DecompiledClassSource.java b/enigma/src/main/java/cuchaz/enigma/source/DecompiledClassSource.java new file mode 100644 index 0000000..85fba50 --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/source/DecompiledClassSource.java @@ -0,0 +1,157 @@ +package cuchaz.enigma.source; + +import java.util.*; + +import javax.annotation.Nullable; + +import cuchaz.enigma.EnigmaProject; +import cuchaz.enigma.EnigmaServices; +import cuchaz.enigma.analysis.EntryReference; +import cuchaz.enigma.api.service.NameProposalService; +import cuchaz.enigma.translation.LocalNameGenerator; +import cuchaz.enigma.translation.Translator; +import cuchaz.enigma.translation.mapping.EntryRemapper; +import cuchaz.enigma.translation.mapping.ResolutionStrategy; +import cuchaz.enigma.translation.representation.TypeDescriptor; +import cuchaz.enigma.translation.representation.entry.ClassEntry; +import cuchaz.enigma.translation.representation.entry.Entry; +import cuchaz.enigma.translation.representation.entry.LocalVariableDefEntry; + +public class DecompiledClassSource { + private final ClassEntry classEntry; + + private final SourceIndex obfuscatedIndex; + private SourceIndex remappedIndex; + + private final Map> highlightedTokens = new EnumMap<>(RenamableTokenType.class); + + public DecompiledClassSource(ClassEntry classEntry, SourceIndex index) { + this.classEntry = classEntry; + this.obfuscatedIndex = index; + this.remappedIndex = index; + } + + public static DecompiledClassSource text(ClassEntry classEntry, String text) { + return new DecompiledClassSource(classEntry, new SourceIndex(text)); + } + + public void remapSource(EnigmaProject project, Translator translator) { + highlightedTokens.clear(); + + SourceRemapper remapper = new SourceRemapper(obfuscatedIndex.getSource(), obfuscatedIndex.referenceTokens()); + + SourceRemapper.Result remapResult = remapper.remap((token, movedToken) -> remapToken(project, token, movedToken, translator)); + remappedIndex = obfuscatedIndex.remapTo(remapResult); + } + + private String remapToken(EnigmaProject project, Token token, Token movedToken, Translator translator) { + EntryReference, Entry> reference = obfuscatedIndex.getReference(token); + + Entry entry = reference.getNameableEntry(); + Entry translatedEntry = translator.translate(entry); + + if (project.isRenamable(reference)) { + if (isDeobfuscated(entry, translatedEntry)) { + highlightToken(movedToken, RenamableTokenType.DEOBFUSCATED); + return translatedEntry.getSourceRemapName(); + } else { + Optional proposedName = proposeName(project, entry); + if (proposedName.isPresent()) { + highlightToken(movedToken, RenamableTokenType.PROPOSED); + return proposedName.get(); + } + + highlightToken(movedToken, RenamableTokenType.OBFUSCATED); + } + } + + String defaultName = generateDefaultName(translatedEntry); + if (defaultName != null) { + return defaultName; + } + + return null; + } + + private Optional proposeName(EnigmaProject project, Entry entry) { + EnigmaServices services = project.getEnigma().getServices(); + + return services.get(NameProposalService.TYPE).stream().flatMap(nameProposalService -> { + EntryRemapper mapper = project.getMapper(); + Collection> resolved = mapper.getObfResolver().resolveEntry(entry, ResolutionStrategy.RESOLVE_ROOT); + + return resolved.stream() + .map(e -> nameProposalService.proposeName(e, mapper)) + .filter(Optional::isPresent) + .map(Optional::get); + }).findFirst(); + } + + @Nullable + private String generateDefaultName(Entry entry) { + if (entry instanceof LocalVariableDefEntry) { + LocalVariableDefEntry localVariable = (LocalVariableDefEntry) entry; + + int index = localVariable.getIndex(); + if (localVariable.isArgument()) { + List arguments = localVariable.getParent().getDesc().getArgumentDescs(); + return LocalNameGenerator.generateArgumentName(index, localVariable.getDesc(), arguments); + } else { + return LocalNameGenerator.generateLocalVariableName(index, localVariable.getDesc()); + } + } + + return null; + } + + private boolean isDeobfuscated(Entry entry, Entry translatedEntry) { + return !entry.getName().equals(translatedEntry.getName()); + } + + public ClassEntry getEntry() { + return classEntry; + } + + public SourceIndex getIndex() { + return remappedIndex; + } + + public Map> getHighlightedTokens() { + return highlightedTokens; + } + + private void highlightToken(Token token, RenamableTokenType highlightType) { + highlightedTokens.computeIfAbsent(highlightType, t -> new ArrayList<>()).add(token); + } + + public int getObfuscatedOffset(int deobfOffset) { + return getOffset(remappedIndex, obfuscatedIndex, deobfOffset); + } + + public int getDeobfuscatedOffset(int obfOffset) { + return getOffset(obfuscatedIndex, remappedIndex, obfOffset); + } + + private static int getOffset(SourceIndex fromIndex, SourceIndex toIndex, int fromOffset) { + int relativeOffset = 0; + + Iterator fromTokenItr = fromIndex.referenceTokens().iterator(); + Iterator toTokenItr = toIndex.referenceTokens().iterator(); + while (fromTokenItr.hasNext() && toTokenItr.hasNext()) { + Token fromToken = fromTokenItr.next(); + Token toToken = toTokenItr.next(); + if (fromToken.end > fromOffset) { + break; + } + + relativeOffset = toToken.end - fromToken.end; + } + + return fromOffset + relativeOffset; + } + + @Override + public String toString() { + return remappedIndex.getSource(); + } +} diff --git a/enigma/src/main/java/cuchaz/enigma/source/RenamableTokenType.java b/enigma/src/main/java/cuchaz/enigma/source/RenamableTokenType.java new file mode 100644 index 0000000..c63aad9 --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/source/RenamableTokenType.java @@ -0,0 +1,7 @@ +package cuchaz.enigma.source; + +public enum RenamableTokenType { + OBFUSCATED, + DEOBFUSCATED, + PROPOSED +} diff --git a/enigma/src/main/java/cuchaz/enigma/translation/LocalNameGenerator.java b/enigma/src/main/java/cuchaz/enigma/translation/LocalNameGenerator.java index 18c966c..dec75ff 100644 --- a/enigma/src/main/java/cuchaz/enigma/translation/LocalNameGenerator.java +++ b/enigma/src/main/java/cuchaz/enigma/translation/LocalNameGenerator.java @@ -1,18 +1,18 @@ package cuchaz.enigma.translation; -import cuchaz.enigma.translation.mapping.NameValidator; -import cuchaz.enigma.translation.representation.TypeDescriptor; - import java.util.Collection; import java.util.Locale; +import cuchaz.enigma.translation.mapping.IdentifierValidation; +import cuchaz.enigma.translation.representation.TypeDescriptor; + public class LocalNameGenerator { public static String generateArgumentName(int index, TypeDescriptor desc, Collection arguments) { boolean uniqueType = arguments.stream().filter(desc::equals).count() <= 1; String translatedName; int nameIndex = index + 1; StringBuilder nameBuilder = new StringBuilder(getTypeName(desc)); - if (!uniqueType || NameValidator.isReserved(nameBuilder.toString())) { + if (!uniqueType || IdentifierValidation.isReservedMethodName(nameBuilder.toString())) { nameBuilder.append(nameIndex); } translatedName = nameBuilder.toString(); diff --git a/enigma/src/main/java/cuchaz/enigma/translation/mapping/EntryRemapper.java b/enigma/src/main/java/cuchaz/enigma/translation/mapping/EntryRemapper.java index 1dd7eac..932b5bb 100644 --- a/enigma/src/main/java/cuchaz/enigma/translation/mapping/EntryRemapper.java +++ b/enigma/src/main/java/cuchaz/enigma/translation/mapping/EntryRemapper.java @@ -1,5 +1,10 @@ package cuchaz.enigma.translation.mapping; +import java.util.Collection; +import java.util.stream.Stream; + +import javax.annotation.Nullable; + import cuchaz.enigma.analysis.index.JarIndex; import cuchaz.enigma.translation.MappingTranslator; import cuchaz.enigma.translation.Translatable; @@ -8,10 +13,7 @@ import cuchaz.enigma.translation.mapping.tree.DeltaTrackingTree; import cuchaz.enigma.translation.mapping.tree.EntryTree; import cuchaz.enigma.translation.mapping.tree.HashEntryTree; import cuchaz.enigma.translation.representation.entry.Entry; - -import javax.annotation.Nullable; -import java.util.Collection; -import java.util.stream.Stream; +import cuchaz.enigma.utils.validation.ValidationContext; public class EntryRemapper { private final DeltaTrackingTree obfToDeobf; @@ -39,26 +41,32 @@ public class EntryRemapper { return new EntryRemapper(index, new HashEntryTree<>()); } - public > void mapFromObf(E obfuscatedEntry, @Nullable EntryMapping deobfMapping) { - mapFromObf(obfuscatedEntry, deobfMapping, true); + public > void mapFromObf(ValidationContext vc, E obfuscatedEntry, @Nullable EntryMapping deobfMapping) { + mapFromObf(vc, obfuscatedEntry, deobfMapping, true); } - public > void mapFromObf(E obfuscatedEntry, @Nullable EntryMapping deobfMapping, boolean renaming) { + public > void mapFromObf(ValidationContext vc, E obfuscatedEntry, @Nullable EntryMapping deobfMapping, boolean renaming) { + mapFromObf(vc, obfuscatedEntry, deobfMapping, renaming, false); + } + + public > void mapFromObf(ValidationContext vc, E obfuscatedEntry, @Nullable EntryMapping deobfMapping, boolean renaming, boolean validateOnly) { Collection resolvedEntries = obfResolver.resolveEntry(obfuscatedEntry, renaming ? ResolutionStrategy.RESOLVE_ROOT : ResolutionStrategy.RESOLVE_CLOSEST); if (renaming && deobfMapping != null) { for (E resolvedEntry : resolvedEntries) { - validator.validateRename(resolvedEntry, deobfMapping.getTargetName()); + validator.validateRename(vc, resolvedEntry, deobfMapping.getTargetName()); } } + if (validateOnly || !vc.canProceed()) return; + for (E resolvedEntry : resolvedEntries) { obfToDeobf.insert(resolvedEntry, deobfMapping); } } - public void removeByObf(Entry obfuscatedEntry) { - mapFromObf(obfuscatedEntry, null); + public void removeByObf(ValidationContext vc, Entry obfuscatedEntry) { + mapFromObf(vc, obfuscatedEntry, null); } @Nullable diff --git a/enigma/src/main/java/cuchaz/enigma/translation/mapping/IdentifierValidation.java b/enigma/src/main/java/cuchaz/enigma/translation/mapping/IdentifierValidation.java new file mode 100644 index 0000000..097c9e9 --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/translation/mapping/IdentifierValidation.java @@ -0,0 +1,79 @@ +/******************************************************************************* + * Copyright (c) 2015 Jeff Martin. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the GNU Lesser General Public + * License v3.0 which accompanies this distribution, and is available at + * http://www.gnu.org/licenses/lgpl.html + *

+ * Contributors: + * Jeff Martin - initial API and implementation + ******************************************************************************/ + +package cuchaz.enigma.translation.mapping; + +import java.util.Arrays; +import java.util.List; + +import cuchaz.enigma.utils.validation.Message; +import cuchaz.enigma.utils.validation.StandardValidation; +import cuchaz.enigma.utils.validation.ValidationContext; + +public final class IdentifierValidation { + + private IdentifierValidation() { + } + + private static final List ILLEGAL_IDENTIFIERS = Arrays.asList( + "abstract", "continue", "for", "new", "switch", "assert", "default", "goto", "package", "synchronized", + "boolean", "do", "if", "private", "this", "break", "double", "implements", "protected", "throw", "byte", + "else", "import", "public", "throws", "case", "enum", "instanceof", "return", "transient", "catch", + "extends", "int", "short", "try", "char", "final", "interface", "static", "void", "class", "finally", + "long", "strictfp", "volatile", "const", "float", "native", "super", "while", "_" + ); + + public static boolean validateClassName(ValidationContext vc, String name) { + if (!StandardValidation.notBlank(vc, name)) return false; + String[] parts = name.split("/"); + for (String part : parts) { + validateIdentifier(vc, part); + } + return true; + } + + public static boolean validateIdentifier(ValidationContext vc, String name) { + if (!StandardValidation.notBlank(vc, name)) return false; + if (checkForReservedName(vc, name)) return false; + + // Adapted from javax.lang.model.SourceVersion.isIdentifier + + int cp = name.codePointAt(0); + int position = 1; + if (!Character.isJavaIdentifierStart(cp)) { + vc.raise(Message.ILLEGAL_IDENTIFIER, name, new String(Character.toChars(cp)), position); + return false; + } + for (int i = Character.charCount(cp); i < name.length(); i += Character.charCount(cp)) { + cp = name.codePointAt(i); + position += 1; + if (!Character.isJavaIdentifierPart(cp)) { + vc.raise(Message.ILLEGAL_IDENTIFIER, name, new String(Character.toChars(cp)), position); + return false; + } + } + + return true; + } + + private static boolean checkForReservedName(ValidationContext vc, String name) { + if (isReservedMethodName(name)) { + vc.raise(Message.RESERVED_IDENTIFIER); + return true; + } + return false; + } + + public static boolean isReservedMethodName(String name) { + return ILLEGAL_IDENTIFIERS.contains(name); + } + +} diff --git a/enigma/src/main/java/cuchaz/enigma/translation/mapping/IllegalNameException.java b/enigma/src/main/java/cuchaz/enigma/translation/mapping/IllegalNameException.java deleted file mode 100644 index a7f83cd..0000000 --- a/enigma/src/main/java/cuchaz/enigma/translation/mapping/IllegalNameException.java +++ /dev/null @@ -1,39 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 Jeff Martin. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Lesser General Public - * License v3.0 which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/lgpl.html - *

- * Contributors: - * Jeff Martin - initial API and implementation - ******************************************************************************/ - -package cuchaz.enigma.translation.mapping; - -public class IllegalNameException extends RuntimeException { - - private String name; - private String reason; - - public IllegalNameException(String name, String reason) { - this.name = name; - this.reason = reason; - } - - public String getReason() { - return this.reason; - } - - @Override - public String getMessage() { - StringBuilder buf = new StringBuilder(); - buf.append("Illegal name: "); - buf.append(this.name); - if (this.reason != null) { - buf.append(" because "); - buf.append(this.reason); - } - return buf.toString(); - } -} diff --git a/enigma/src/main/java/cuchaz/enigma/translation/mapping/MappingValidator.java b/enigma/src/main/java/cuchaz/enigma/translation/mapping/MappingValidator.java index ae615da..f9f3b88 100644 --- a/enigma/src/main/java/cuchaz/enigma/translation/mapping/MappingValidator.java +++ b/enigma/src/main/java/cuchaz/enigma/translation/mapping/MappingValidator.java @@ -1,17 +1,20 @@ package cuchaz.enigma.translation.mapping; +import java.util.Collection; +import java.util.HashSet; +import java.util.stream.Collectors; + import cuchaz.enigma.analysis.index.InheritanceIndex; import cuchaz.enigma.analysis.index.JarIndex; import cuchaz.enigma.translation.Translator; import cuchaz.enigma.translation.mapping.tree.EntryTree; import cuchaz.enigma.translation.representation.entry.ClassEntry; import cuchaz.enigma.translation.representation.entry.Entry; - -import java.util.Collection; -import java.util.HashSet; -import java.util.stream.Collectors; +import cuchaz.enigma.utils.validation.Message; +import cuchaz.enigma.utils.validation.ValidationContext; public class MappingValidator { + private final EntryTree obfToDeobf; private final Translator deobfuscator; private final JarIndex index; @@ -22,15 +25,15 @@ public class MappingValidator { this.index = index; } - public void validateRename(Entry entry, String name) throws IllegalNameException { + public void validateRename(ValidationContext vc, Entry entry, String name) { Collection> equivalentEntries = index.getEntryResolver().resolveEquivalentEntries(entry); for (Entry equivalentEntry : equivalentEntries) { - equivalentEntry.validateName(name); - validateUnique(equivalentEntry, name); + equivalentEntry.validateName(vc, name); + validateUnique(vc, equivalentEntry, name); } } - private void validateUnique(Entry entry, String name) { + private void validateUnique(ValidationContext vc, Entry entry, String name) { ClassEntry containingClass = entry.getContainingClass(); Collection relatedClasses = getRelatedClasses(containingClass); @@ -45,9 +48,9 @@ public class MappingValidator { if (!isUnique(translatedEntry, translatedSiblings, name)) { Entry parent = translatedEntry.getParent(); if (parent != null) { - throw new IllegalNameException(name, "Name is not unique in " + parent + "!"); + vc.raise(Message.NONUNIQUE_NAME_CLASS, name, parent); } else { - throw new IllegalNameException(name, "Name is not unique!"); + vc.raise(Message.NONUNIQUE_NAME, name); } } } @@ -72,4 +75,5 @@ public class MappingValidator { } return true; } + } diff --git a/enigma/src/main/java/cuchaz/enigma/translation/mapping/NameValidator.java b/enigma/src/main/java/cuchaz/enigma/translation/mapping/NameValidator.java deleted file mode 100644 index 74ba633..0000000 --- a/enigma/src/main/java/cuchaz/enigma/translation/mapping/NameValidator.java +++ /dev/null @@ -1,50 +0,0 @@ -/******************************************************************************* - * Copyright (c) 2015 Jeff Martin. - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the GNU Lesser General Public - * License v3.0 which accompanies this distribution, and is available at - * http://www.gnu.org/licenses/lgpl.html - *

- * Contributors: - * Jeff Martin - initial API and implementation - ******************************************************************************/ - -package cuchaz.enigma.translation.mapping; - -import java.util.Arrays; -import java.util.List; -import java.util.regex.Pattern; - -public class NameValidator { - private static final Pattern IDENTIFIER_PATTERN; - private static final Pattern CLASS_PATTERN; - private static final List ILLEGAL_IDENTIFIERS = Arrays.asList( - "abstract", "continue", "for", "new", "switch", "assert", "default", "goto", "package", "synchronized", - "boolean", "do", "if", "private", "this", "break", "double", "implements", "protected", "throw", "byte", - "else", "import", "public", "throws", "case", "enum", "instanceof", "return", "transient", "catch", - "extends", "int", "short", "try", "char", "final", "interface", "static", "void", "class", "finally", - "long", "strictfp", "volatile", "const", "float", "native", "super", "while", "_" - ); - - static { - String identifierRegex = "[A-Za-z_<][A-Za-z0-9_>]*"; - IDENTIFIER_PATTERN = Pattern.compile(identifierRegex); - CLASS_PATTERN = Pattern.compile(String.format("^(%s(\\.|/))*(%s)$", identifierRegex, identifierRegex)); - } - - public static void validateClassName(String name) { - if (!CLASS_PATTERN.matcher(name).matches() || ILLEGAL_IDENTIFIERS.contains(name)) { - throw new IllegalNameException(name, "This doesn't look like a legal class name"); - } - } - - public static void validateIdentifier(String name) { - if (!IDENTIFIER_PATTERN.matcher(name).matches() || ILLEGAL_IDENTIFIERS.contains(name)) { - throw new IllegalNameException(name, "This doesn't look like a legal identifier"); - } - } - - public static boolean isReserved(String name) { - return ILLEGAL_IDENTIFIERS.contains(name); - } -} diff --git a/enigma/src/main/java/cuchaz/enigma/translation/representation/entry/ClassEntry.java b/enigma/src/main/java/cuchaz/enigma/translation/representation/entry/ClassEntry.java index 7d4b2ba..15b0a9b 100644 --- a/enigma/src/main/java/cuchaz/enigma/translation/representation/entry/ClassEntry.java +++ b/enigma/src/main/java/cuchaz/enigma/translation/representation/entry/ClassEntry.java @@ -11,16 +11,17 @@ package cuchaz.enigma.translation.representation.entry; -import cuchaz.enigma.translation.mapping.IllegalNameException; -import cuchaz.enigma.translation.Translator; -import cuchaz.enigma.translation.mapping.EntryMapping; -import cuchaz.enigma.translation.mapping.NameValidator; -import cuchaz.enigma.translation.representation.TypeDescriptor; +import java.util.List; +import java.util.Objects; import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.util.List; -import java.util.Objects; + +import cuchaz.enigma.translation.Translator; +import cuchaz.enigma.translation.mapping.EntryMapping; +import cuchaz.enigma.translation.mapping.IdentifierValidation; +import cuchaz.enigma.translation.representation.TypeDescriptor; +import cuchaz.enigma.utils.validation.ValidationContext; public class ClassEntry extends ParentedEntry implements Comparable { private final String fullName; @@ -97,8 +98,8 @@ public class ClassEntry extends ParentedEntry implements Comparable< } @Override - public void validateName(String name) throws IllegalNameException { - NameValidator.validateClassName(name); + public void validateName(ValidationContext vc, String name) { + IdentifierValidation.validateClassName(vc, name); } @Override diff --git a/enigma/src/main/java/cuchaz/enigma/translation/representation/entry/Entry.java b/enigma/src/main/java/cuchaz/enigma/translation/representation/entry/Entry.java index 40bff31..ff392fe 100644 --- a/enigma/src/main/java/cuchaz/enigma/translation/representation/entry/Entry.java +++ b/enigma/src/main/java/cuchaz/enigma/translation/representation/entry/Entry.java @@ -11,14 +11,15 @@ package cuchaz.enigma.translation.representation.entry; -import cuchaz.enigma.translation.mapping.IllegalNameException; -import cuchaz.enigma.translation.Translatable; -import cuchaz.enigma.translation.mapping.NameValidator; - -import javax.annotation.Nullable; import java.util.ArrayList; import java.util.List; +import javax.annotation.Nullable; + +import cuchaz.enigma.translation.Translatable; +import cuchaz.enigma.translation.mapping.IdentifierValidation; +import cuchaz.enigma.utils.validation.ValidationContext; + public interface Entry

> extends Translatable { String getName(); @@ -92,8 +93,8 @@ public interface Entry

> extends Translatable { return withParent((P) parent.replaceAncestor(target, replacement)); } - default void validateName(String name) throws IllegalNameException { - NameValidator.validateIdentifier(name); + default void validateName(ValidationContext vc, String name) { + IdentifierValidation.validateIdentifier(vc, name); } @SuppressWarnings("unchecked") diff --git a/enigma/src/main/java/cuchaz/enigma/utils/I18n.java b/enigma/src/main/java/cuchaz/enigma/utils/I18n.java index e18532b..cb498e0 100644 --- a/enigma/src/main/java/cuchaz/enigma/utils/I18n.java +++ b/enigma/src/main/java/cuchaz/enigma/utils/I18n.java @@ -5,9 +5,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Map; +import java.util.*; +import java.util.stream.Collectors; import java.util.stream.Stream; import com.google.common.collect.ImmutableList; @@ -22,12 +21,12 @@ public class I18n { private static Map translations = Maps.newHashMap(); private static Map defaultTranslations = Maps.newHashMap(); private static Map languageNames = Maps.newHashMap(); - + static { defaultTranslations = load(DEFAULT_LANGUAGE); translations = defaultTranslations; } - + @SuppressWarnings("unchecked") public static Map load(String language) { try (InputStream inputStream = I18n.class.getResourceAsStream("/lang/" + language + ".json")) { @@ -41,30 +40,50 @@ public class I18n { } return Collections.emptyMap(); } - - public static String translate(String key) { + + public static String translateOrNull(String key) { String value = translations.get(key); - if (value != null) { - return value; + if (value != null) return value; + + return defaultTranslations.get(key); + } + + public static String translate(String key) { + String tr = translateOrNull(key); + return tr != null ? tr : key; + } + + public static String translateOrEmpty(String key, Object... args) { + String text = translateOrNull(key); + if (text != null) { + return String.format(text, args); + } else { + return ""; } - value = defaultTranslations.get(key); - if (value != null) { - return value; + } + + public static String translateFormatted(String key, Object... args) { + String text = translateOrNull(key); + if (text != null) { + return String.format(text, args); + } else if (args.length == 0) { + return key; + } else { + return key + Arrays.stream(args).map(Objects::toString).collect(Collectors.joining(", ", "[", "]")); } - return key; } - + public static String getLanguageName(String language) { return languageNames.get(language); } - + public static void setLanguage(String language) { translations = load(language); } - + public static ArrayList getAvailableLanguages() { ArrayList list = new ArrayList(); - + try { ImmutableList resources = ClassPath.from(Thread.currentThread().getContextClassLoader()).getResources().asList(); Stream dirStream = resources.stream(); @@ -81,7 +100,7 @@ public class I18n { } return list; } - + private static void loadLanguageName(String fileName) { try (InputStream stream = Thread.currentThread().getContextClassLoader().getResourceAsStream("lang/" + fileName + ".json")) { try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { diff --git a/enigma/src/main/java/cuchaz/enigma/utils/Result.java b/enigma/src/main/java/cuchaz/enigma/utils/Result.java new file mode 100644 index 0000000..dcaabd5 --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/utils/Result.java @@ -0,0 +1,108 @@ +package cuchaz.enigma.utils; + +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +public final class Result { + + private final T ok; + private final E err; + + private Result(T ok, E err) { + this.ok = ok; + this.err = err; + } + + public static Result ok(T ok) { + return new Result<>(Objects.requireNonNull(ok), null); + } + + public static Result err(E err) { + return new Result<>(null, Objects.requireNonNull(err)); + } + + public boolean isOk() { + return this.ok != null; + } + + public boolean isErr() { + return this.err != null; + } + + public Optional ok() { + return Optional.ofNullable(this.ok); + } + + public Optional err() { + return Optional.ofNullable(this.err); + } + + public T unwrap() { + if (this.isOk()) return this.ok; + throw new IllegalStateException(String.format("Called Result.unwrap on an Err value: %s", this.err)); + } + + public E unwrapErr() { + if (this.isErr()) return this.err; + throw new IllegalStateException(String.format("Called Result.unwrapErr on an Ok value: %s", this.ok)); + } + + public T unwrapOr(T fallback) { + if (this.isOk()) return this.ok; + return fallback; + } + + public T unwrapOrElse(Function fn) { + if (this.isOk()) return this.ok; + return fn.apply(this.err); + } + + @SuppressWarnings("unchecked") + public Result map(Function op) { + if (!this.isOk()) return (Result) this; + return Result.ok(op.apply(this.ok)); + } + + @SuppressWarnings("unchecked") + public Result mapErr(Function op) { + if (!this.isErr()) return (Result) this; + return Result.err(op.apply(this.err)); + } + + @SuppressWarnings("unchecked") + public Result and(Result next) { + if (this.isErr()) return (Result) this; + return next; + } + + @SuppressWarnings("unchecked") + public Result andThen(Function> op) { + if (this.isErr()) return (Result) this; + return op.apply(this.ok); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Result result = (Result) o; + return Objects.equals(ok, result.ok) && + Objects.equals(err, result.err); + } + + @Override + public int hashCode() { + return Objects.hash(ok, err); + } + + @Override + public String toString() { + if (this.isOk()) { + return String.format("Result.Ok(%s)", this.ok); + } else { + return String.format("Result.Err(%s)", this.err); + } + } + +} diff --git a/enigma/src/main/java/cuchaz/enigma/utils/Utils.java b/enigma/src/main/java/cuchaz/enigma/utils/Utils.java index 2664099..8beaaae 100644 --- a/enigma/src/main/java/cuchaz/enigma/utils/Utils.java +++ b/enigma/src/main/java/cuchaz/enigma/utils/Utils.java @@ -25,6 +25,8 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.concurrent.locks.Lock; +import java.util.function.Supplier; import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -78,6 +80,25 @@ public class Utils { return digest.digest(); } + public static void withLock(Lock l, Runnable op) { + try { + l.lock(); + op.run(); + } finally { + l.unlock(); + } + } + + public static R withLock(Lock l, Supplier op) { + try { + l.lock(); + return op.get(); + } finally { + l.unlock(); + } + } + + public static boolean isBlank(String input) { if (input == null) { return true; diff --git a/enigma/src/main/java/cuchaz/enigma/utils/validation/Message.java b/enigma/src/main/java/cuchaz/enigma/utils/validation/Message.java new file mode 100644 index 0000000..dca74bc --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/utils/validation/Message.java @@ -0,0 +1,48 @@ +package cuchaz.enigma.utils.validation; + +import cuchaz.enigma.utils.I18n; + +public class Message { + + public static final Message EMPTY_FIELD = create(Type.ERROR, "empty_field"); + public static final Message NOT_INT = create(Type.ERROR, "not_int"); + public static final Message FIELD_OUT_OF_RANGE_INT = create(Type.ERROR, "field_out_of_range_int"); + public static final Message FIELD_LENGTH_OUT_OF_RANGE = create(Type.ERROR, "field_length_out_of_range"); + public static final Message NONUNIQUE_NAME_CLASS = create(Type.ERROR, "nonunique_name_class"); + public static final Message NONUNIQUE_NAME = create(Type.ERROR, "nonunique_name"); + public static final Message ILLEGAL_CLASS_NAME = create(Type.ERROR, "illegal_class_name"); + public static final Message ILLEGAL_IDENTIFIER = create(Type.ERROR, "illegal_identifier"); + public static final Message RESERVED_IDENTIFIER = create(Type.ERROR, "reserved_identifier"); + public static final Message ILLEGAL_DOC_COMMENT_END = create(Type.ERROR, "illegal_doc_comment_end"); + + public static final Message STYLE_VIOLATION = create(Type.WARNING, "style_violation"); + + public final Type type; + public final String textKey; + public final String longTextKey; + + private Message(Type type, String textKey, String longTextKey) { + this.type = type; + this.textKey = textKey; + this.longTextKey = longTextKey; + } + + public String format(Object[] args) { + return I18n.translateFormatted(textKey, args); + } + + public String formatDetails(Object[] args) { + return I18n.translateOrEmpty(longTextKey, args); + } + + public static Message create(Type type, String name) { + return new Message(type, String.format("validation.message.%s", name), String.format("validation.message.%s.long", name)); + } + + public enum Type { + INFO, + WARNING, + ERROR, + } + +} diff --git a/enigma/src/main/java/cuchaz/enigma/utils/validation/ParameterizedMessage.java b/enigma/src/main/java/cuchaz/enigma/utils/validation/ParameterizedMessage.java new file mode 100644 index 0000000..56b0ecc --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/utils/validation/ParameterizedMessage.java @@ -0,0 +1,45 @@ +package cuchaz.enigma.utils.validation; + +import java.util.Arrays; +import java.util.Objects; + +public class ParameterizedMessage { + + public final Message message; + private final Object[] params; + + public ParameterizedMessage(Message message, Object[] params) { + this.message = message; + this.params = params; + } + + public String getText() { + return message.format(params); + } + + public String getLongText() { + return message.formatDetails(params); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ParameterizedMessage that = (ParameterizedMessage) o; + return Objects.equals(message, that.message) && + Arrays.equals(params, that.params); + } + + @Override + public int hashCode() { + int result = Objects.hash(message); + result = 31 * result + Arrays.hashCode(params); + return result; + } + + @Override + public String toString() { + return String.format("ParameterizedMessage { message: %s, params: %s }", message, Arrays.toString(params)); + } + +} diff --git a/enigma/src/main/java/cuchaz/enigma/utils/validation/PrintValidatable.java b/enigma/src/main/java/cuchaz/enigma/utils/validation/PrintValidatable.java new file mode 100644 index 0000000..fe91cc1 --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/utils/validation/PrintValidatable.java @@ -0,0 +1,37 @@ +package cuchaz.enigma.utils.validation; + +import java.util.Arrays; + +public class PrintValidatable implements Validatable { + + public static final PrintValidatable INSTANCE = new PrintValidatable(); + + @Override + public void addMessage(ParameterizedMessage message) { + String text = message.getText(); + String longText = message.getLongText(); + String type; + switch (message.message.type) { + case INFO: + type = "info"; + break; + case WARNING: + type = "warning"; + break; + case ERROR: + type = "error"; + break; + default: + throw new IllegalStateException("unreachable"); + } + System.out.printf("%s: %s\n", type, text); + if (!longText.isEmpty()) { + Arrays.stream(longText.split("\n")).forEach(s -> System.out.printf(" %s\n", s)); + } + } + + @Override + public void clearMessages() { + } + +} diff --git a/enigma/src/main/java/cuchaz/enigma/utils/validation/StandardValidation.java b/enigma/src/main/java/cuchaz/enigma/utils/validation/StandardValidation.java new file mode 100644 index 0000000..871b59d --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/utils/validation/StandardValidation.java @@ -0,0 +1,34 @@ +package cuchaz.enigma.utils.validation; + +public class StandardValidation { + + public static boolean notBlank(ValidationContext vc, String value) { + if (value.trim().isEmpty()) { + vc.raise(Message.EMPTY_FIELD); + return false; + } + return true; + } + + public static boolean isInt(ValidationContext vc, String value) { + if (!notBlank(vc, value)) return false; + try { + Integer.parseInt(value); + return true; + } catch (NumberFormatException e) { + vc.raise(Message.NOT_INT); + return false; + } + } + + public static boolean isIntInRange(ValidationContext vc, String value, int min, int max) { + if (!isInt(vc, value)) return false; + int intVal = Integer.parseInt(value); + if (intVal < min || intVal > max) { + vc.raise(Message.FIELD_OUT_OF_RANGE_INT, min, max); + return false; + } + return true; + } + +} diff --git a/enigma/src/main/java/cuchaz/enigma/utils/validation/Validatable.java b/enigma/src/main/java/cuchaz/enigma/utils/validation/Validatable.java new file mode 100644 index 0000000..765ee08 --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/utils/validation/Validatable.java @@ -0,0 +1,9 @@ +package cuchaz.enigma.utils.validation; + +public interface Validatable { + + void addMessage(ParameterizedMessage message); + + void clearMessages(); + +} diff --git a/enigma/src/main/java/cuchaz/enigma/utils/validation/ValidationContext.java b/enigma/src/main/java/cuchaz/enigma/utils/validation/ValidationContext.java new file mode 100644 index 0000000..d38fc21 --- /dev/null +++ b/enigma/src/main/java/cuchaz/enigma/utils/validation/ValidationContext.java @@ -0,0 +1,78 @@ +package cuchaz.enigma.utils.validation; + +import java.util.*; + +import javax.annotation.Nullable; + +import cuchaz.enigma.utils.validation.Message.Type; + +/** + * A context for user input validation. Handles collecting error messages and + * displaying the errors on the relevant input fields. UIs using validation + * often have two stages of applying changes: validating all the input fields, + * then checking if there's any errors or unconfirmed warnings, and if not, + * then actually applying the changes. This allows for easily collecting + * multiple errors and displaying them to the user at the same time. + */ +public class ValidationContext { + + private Validatable activeElement = null; + private final Set elements = new HashSet<>(); + private final List messages = new ArrayList<>(); + + /** + * Sets the currently active element (such as an input field). Any messages + * raised while this is set get displayed on this element. + * + * @param v the active element to set, or {@code null} to unset + */ + public void setActiveElement(@Nullable Validatable v) { + if (v != null) { + elements.add(v); + } + activeElement = v; + } + + /** + * Raises a message. If there's a currently active element, also notifies + * that element about the message. + * + * @param message the message to raise + * @param args the arguments used when formatting the message text + */ + public void raise(Message message, Object... args) { + ParameterizedMessage pm = new ParameterizedMessage(message, args); + if (activeElement != null) { + activeElement.addMessage(pm); + } + messages.add(pm); + } + + /** + * Returns whether the validation context currently has no messages that + * block executing actions, such as errors and unconfirmed warnings. + * + * @return whether the program can proceed executing and the UI is in a + * valid state + */ + public boolean canProceed() { + // TODO on warnings, wait until user confirms + return messages.stream().noneMatch(m -> m.message.type == Type.ERROR); + } + + public List getMessages() { + return Collections.unmodifiableList(messages); + } + + /** + * Clears all currently pending messages. This should be called whenever the + * interface starts getting validated, to get rid of old messages. + */ + public void reset() { + activeElement = null; + elements.forEach(Validatable::clearMessages); + elements.clear(); + messages.clear(); + } + +} diff --git a/enigma/src/main/resources/lang/de_de.json b/enigma/src/main/resources/lang/de_de.json new file mode 100644 index 0000000..ef41da1 --- /dev/null +++ b/enigma/src/main/resources/lang/de_de.json @@ -0,0 +1,26 @@ +{ + "language": "German", + + "general.retry": "Wiederholen", + + "popup_menu.editor_tab.close": "Schließen", + "popup_menu.editor_tab.close_all": "Alle schließen", + "popup_menu.editor_tab.close_others": "Andere schließen", + "popup_menu.editor_tab.close_left": "Alle links hiervon schließen", + "popup_menu.editor_tab.close_right": "Alle rechts hiervon schließen", + + "editor.decompiling": "Dekompiliere...", + "editor.decompile_error": "Ein Fehler ist während des Dekompilierens aufgetreten.", + + "validation.message.empty_field": "Dieses Feld muss ausgefüllt werden.", + "validation.message.not_int": "Wert muss eine ganze Zahl sein.", + "validation.message.field_out_of_range_int": "Wert muss eine ganze Zahl zwischen %d und %d sein.", + "validation.message.field_length_out_of_range": "Wert muss kürzer als %d Zeichen sein.", + "validation.message.nonunique_name_class": "Name „%s“ ist in „%s“ nicht eindeutig.", + "validation.message.nonunique_name": "Name „%s“ ist nicht eindeutig.", + "validation.message.illegal_class_name": "„%s“ ist kein gültiger Klassenname.", + "validation.message.illegal_identifier": "„%s“ ist kein gültiger Name.", + "validation.message.illegal_identifier.long": "Ungültiges Zeichen „%2$s“ an Position %3$d.", + "validation.message.illegal_doc_comment_end": "Javadoc-Kommentar darf die Zeichenfolge „*/“ nicht enthalten.", + "validation.message.reserved_identifier": "„%s“ ist ein reservierter Name." +} \ No newline at end of file diff --git a/enigma/src/main/resources/lang/en_us.json b/enigma/src/main/resources/lang/en_us.json index dbf4b93..b8db4b0 100644 --- a/enigma/src/main/resources/lang/en_us.json +++ b/enigma/src/main/resources/lang/en_us.json @@ -1,6 +1,8 @@ { "language": "English", + "general.retry": "Retry", + "mapping_format.enigma_file": "Enigma File", "mapping_format.enigma_directory": "Enigma Directory", "mapping_format.enigma_zip": "Enigma ZIP", @@ -69,6 +71,14 @@ "popup_menu.zoom.in": "Zoom in", "popup_menu.zoom.out": "Zoom out", "popup_menu.zoom.reset": "Reset zoom", + "popup_menu.editor_tab.close": "Close", + "popup_menu.editor_tab.close_all": "Close All", + "popup_menu.editor_tab.close_others": "Close Others", + "popup_menu.editor_tab.close_left": "Close All to the Left", + "popup_menu.editor_tab.close_right": "Close All to the Right", + + "editor.decompiling": "Decompiling...", + "editor.decompile_error": "An error was encountered while decompiling.", "info_panel.classes.obfuscated": "Obfuscated Classes", "info_panel.classes.deobfuscated": "De-obfuscated Classes", @@ -79,8 +89,8 @@ "info_panel.identifier.method": "Method", "info_panel.identifier.constructor": "Constructor", "info_panel.identifier.class": "Class", - "info_panel.identifier.type_descriptor": "TypeDescriptor", - "info_panel.identifier.method_descriptor": "MethodDescriptor", + "info_panel.identifier.type_descriptor": "Type Descriptor", + "info_panel.identifier.method_descriptor": "Method Descriptor", "info_panel.identifier.modifier": "Modifier", "info_panel.identifier.index": "Index", "info_panel.editor.class.decompiling": "(decompiling...)", @@ -149,12 +159,23 @@ "message.mark_deobf.text": "%s marked %s as deobfuscated", "message.remove_mapping.text": "%s removed mappings for %s", "message.rename.text": "%s renamed %s to %s", - "status.disconnected": "Disconnected.", "status.connected": "Connected.", "status.connected_user_count": "Connected (%d users).", "status.ready": "Ready.", + "validation.message.empty_field": "This field is required.", + "validation.message.not_int": "Value must be an integer.", + "validation.message.field_out_of_range_int": "Value must be an integer between %d and %d.", + "validation.message.field_length_out_of_range": "Value must be less than %d characters long.", + "validation.message.nonunique_name_class": "Name '%s' is not unique in '%s'.", + "validation.message.nonunique_name": "Name '%s' is not unique.", + "validation.message.illegal_class_name": "'%s' is not a valid class name.", + "validation.message.illegal_identifier": "'%s' is not a valid identifier.", + "validation.message.illegal_identifier.long": "Invalid character '%2$s' at position %3$d.", + "validation.message.illegal_doc_comment_end": "Javadoc comment cannot contain the character sequence '*/'.", + "validation.message.reserved_identifier": "'%s' is a reserved identifier.", + "crash.title": "%s - Crash Report", "crash.summary": "%s has crashed! =(", "crash.export": "Export", -- cgit v1.2.3