diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java index ee483a77c..2381013fe 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManagerImpl.java @@ -2,6 +2,7 @@ import com.google.common.base.Charsets; import com.google.common.collect.*; +import com.google.common.io.BaseEncoding; import com.google.common.io.Files; import de.peeeq.wurstio.ModelChangedException; import de.peeeq.wurstio.WurstCompilerJassImpl; @@ -10,6 +11,8 @@ import de.peeeq.wurstscript.WLogger; import de.peeeq.wurstscript.ast.*; import de.peeeq.wurstscript.attributes.CompileError; +import de.peeeq.wurstscript.attributes.prettyPrint.DefaultSpacer; +import de.peeeq.wurstscript.attributes.prettyPrint.PrettyPrinter; import de.peeeq.wurstscript.gui.WurstGui; import de.peeeq.wurstscript.gui.WurstGuiLogger; import de.peeeq.wurstscript.utils.Utils; @@ -18,6 +21,8 @@ import org.eclipse.lsp4j.PublishDiagnosticsParams; import java.io.*; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.*; @@ -47,6 +52,12 @@ public class ModelManagerImpl implements ModelManager { // hashcode for each compilation unit content as string private final Map fileHashcodes = new HashMap<>(); + // hash for each function inside a Jass compilation unit + private final Map> jassFunctionSnapshots = new HashMap<>(); + + // functions that changed in recently modified Jass compilation units + private final Map> pendingJassFunctionChanges = new HashMap<>(); + // file for each compilation unit private final WeakHashMap compilationunitFile = new WeakHashMap<>(); @@ -88,6 +99,10 @@ private List getJassdocCUs(Path jassdoc, WurstGui gui) { @Override public Changes removeCompilationUnit(WFile resource) { parseErrors.remove(resource); + if (isJassFile(resource)) { + jassFunctionSnapshots.remove(resource); + pendingJassFunctionChanges.remove(resource); + } WurstModel model2 = model; if (model2 == null) { return Changes.empty(); @@ -114,6 +129,8 @@ public void clean() { parseErrors.clear(); model = null; dependencies.clear(); + jassFunctionSnapshots.clear(); + pendingJassFunctionChanges.clear(); WLogger.info("Clean done."); } @@ -539,6 +556,17 @@ private CompilationUnit replaceCompilationUnit(WFile filename, String contents, WurstCompilerJassImpl c = getCompiler(gui); CompilationUnit cu = c.parse(filename.toString(), new StringReader(contents)); cu.getCuInfo().setFile(filename.toString()); + + if (isJassFile(filename)) { + Map newFunctions = collectJassFunctions(cu); + Map oldFunctions = jassFunctionSnapshots.getOrDefault(filename, Collections.emptyMap()); + Set changedFunctions = determineChangedJassFunctions(oldFunctions, newFunctions); + pendingJassFunctionChanges.put(filename, changedFunctions); + jassFunctionSnapshots.put(filename, newFunctions); + } else { + pendingJassFunctionChanges.remove(filename); + } + updateModel(cu, gui); fileHashcodes.put(filename, contents.hashCode()); if (reportErrors) { @@ -625,6 +653,7 @@ private void doTypeCheckPartial(WurstGui gui, List toCheckFilenames, Set< Collection toCheckRec = calculateCUsToUpdate(toCheck, oldPackages, model2); partialTypecheck(model2, toCheckRec, gui, comp); + clearPendingJassChanges(toCheckFilenames); } @Override @@ -644,6 +673,7 @@ public void reconcile(Changes changes) { WurstGui gui = new WurstGuiLogger(); WurstCompilerJassImpl comp = getCompiler(gui); partialTypecheck(model2, toCheckRec, gui, comp); + clearPendingJassChanges(changes.getAffectedFiles().toJavaSet()); } private void partialTypecheck(WurstModel model2, Collection toCheckRec, WurstGui gui, WurstCompilerJassImpl comp) { @@ -676,18 +706,26 @@ private Set calculateCUsToUpdate(Collection ch Set result = new TreeSet<>(Comparator.comparing(cu -> cu.getCuInfo().getFile())); result.addAll(changed); - boolean b = false; + Map> changedJassFunctions = new HashMap<>(); + boolean missingJassInfo = false; for (CompilationUnit compilationUnit : changed) { - if (compilationUnit.getCuInfo().getFile().endsWith(".j")) { - b = true; - break; + if (isJassFile(compilationUnit)) { + WFile file = wFile(compilationUnit); + Set changedFunctions = pendingJassFunctionChanges.get(file); + if (changedFunctions == null) { + missingJassInfo = true; + } else { + changedJassFunctions.put(file, changedFunctions); + } } } - if (b) { - // when plain Jass files are changed, everything must be checked again: + if (missingJassInfo) { result.addAll(model); return result; } + if (!changedJassFunctions.isEmpty()) { + addAffectedByJass(changedJassFunctions, model, result); + } // get packages provided by the changed CUs Stream providedPackages = changed.stream() @@ -703,6 +741,236 @@ private Set calculateCUsToUpdate(Collection ch return result; } + private Map collectJassFunctions(CompilationUnit cu) { + Map result = new LinkedHashMap<>(); + for (JassToplevelDeclaration decl : cu.getJassDecls()) { + if (decl instanceof FunctionDefinition) { + FunctionDefinition function = (FunctionDefinition) decl; + result.put(function.getName(), fingerprintJassFunction(function)); + } + } + return result; + } + + private String fingerprintJassFunction(FunctionDefinition function) { + DefaultSpacer spacer = new DefaultSpacer(); + String rendered = function.match(new FunctionDefinition.Matcher() { + @Override + public String case_NativeFunc(NativeFunc nativeFunc) { + return prettyPrint(nativeFunc, spacer); + } + + @Override + public String case_TupleDef(TupleDef tupleDef) { + return prettyPrint(tupleDef, spacer); + } + + @Override + public String case_ExtensionFuncDef(ExtensionFuncDef extensionFuncDef) { + return prettyPrint(extensionFuncDef, spacer); + } + + @Override + public String case_FuncDef(FuncDef funcDef) { + return prettyPrint(funcDef, spacer); + } + }); + return sha256(rendered); + } + + private String prettyPrint(NativeFunc nativeFunc, DefaultSpacer spacer) { + StringBuilder sb = new StringBuilder(); + seedBuilder(sb); + PrettyPrinter.jassPrettyPrint(nativeFunc, spacer, sb, 0); + trimBuilderPrefix(sb); + return sb.toString(); + } + + private String prettyPrint(FuncDef funcDef, DefaultSpacer spacer) { + StringBuilder sb = new StringBuilder(); + seedBuilder(sb); + PrettyPrinter.jassPrettyPrint(funcDef, spacer, sb, 0); + trimBuilderPrefix(sb); + return sb.toString(); + } + + private String prettyPrint(FunctionDefinition function, DefaultSpacer spacer) { + StringBuilder sb = new StringBuilder(); + seedBuilder(sb); + function.prettyPrint(spacer, sb, 0); + trimBuilderPrefix(sb); + return sb.toString(); + } + + private void seedBuilder(StringBuilder sb) { + sb.append('\n').append('\n'); + } + + private void trimBuilderPrefix(StringBuilder sb) { + while (sb.length() > 0 && sb.charAt(0) == '\n') { + sb.deleteCharAt(0); + } + } + + private String sha256(String content) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(content.getBytes(UTF_8)); + return BaseEncoding.base16().lowerCase().encode(hash); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + private Set determineChangedJassFunctions(Map oldFunctions, Map newFunctions) { + Set changed = new HashSet<>(); + for (Map.Entry entry : newFunctions.entrySet()) { + String oldFingerprint = oldFunctions.get(entry.getKey()); + String newFingerprint = entry.getValue(); + if (oldFingerprint == null || !newFingerprint.equals(oldFingerprint)) { + changed.add(entry.getKey()); + } + } + for (String oldName : oldFunctions.keySet()) { + if (!newFunctions.containsKey(oldName)) { + changed.add(oldName); + } + } + return Collections.unmodifiableSet(changed); + } + + private void addAffectedByJass(Map> changedJassFunctions, WurstModel model, Set result) { + boolean hasRealChanges = changedJassFunctions.values().stream().anyMatch(s -> !s.isEmpty()); + if (!hasRealChanges) { + return; + } + for (CompilationUnit cu : model) { + if (result.contains(cu)) { + continue; + } + if (usesChangedJassFunctions(cu, changedJassFunctions)) { + result.add(cu); + } + } + } + + private boolean usesChangedJassFunctions(CompilationUnit cu, Map> changedJassFunctions) { + JassFunctionUsageCollector collector = new JassFunctionUsageCollector(changedJassFunctions); + cu.accept(collector); + return collector.isFound(); + } + + private final class JassFunctionUsageCollector extends Element.DefaultVisitor { + private final Map> changedJassFunctions; + private boolean found = false; + + private JassFunctionUsageCollector(Map> changedJassFunctions) { + this.changedJassFunctions = changedJassFunctions; + } + + private boolean isFound() { + return found; + } + + private void checkFuncRef(FuncRef funcRef) { + if (found) { + return; + } + FunctionDefinition funcDef = null; + try { + funcDef = funcRef.attrFuncDef(); + } catch (RuntimeException ignored) { + // fall back to name-based matching below + } + if (funcDef != null) { + CompilationUnit defCu = funcDef.attrCompilationUnit(); + if (defCu == null) { + return; + } + WFile defFile = wFile(defCu); + Set changed = changedJassFunctions.get(defFile); + if (changed == null || changed.isEmpty()) { + return; + } + if (changed.contains(funcDef.getName())) { + found = true; + } + return; + } + String funcName = funcRef.getFuncName(); + if (functionNameChanged(funcName)) { + found = true; + } + } + + private boolean functionNameChanged(String funcName) { + if (funcName == null) { + return false; + } + for (Set changed : changedJassFunctions.values()) { + if (changed.contains(funcName)) { + return true; + } + } + return false; + } + + @Override + public void visit(ExprFunctionCall e) { + if (found) { + return; + } + checkFuncRef(e); + if (!found) { + super.visit(e); + } + } + + @Override + public void visit(ExprMemberMethodDot e) { + if (found) { + return; + } + checkFuncRef(e); + if (!found) { + super.visit(e); + } + } + + @Override + public void visit(ExprMemberMethodDotDot e) { + if (found) { + return; + } + checkFuncRef(e); + if (!found) { + super.visit(e); + } + } + + @Override + public void visit(ExprFuncRef e) { + if (found) { + return; + } + checkFuncRef(e); + if (!found) { + super.visit(e); + } + } + + @Override + public void visit(Annotation annotation) { + if (found) { + return; + } + checkFuncRef(annotation); + if (!found) { + super.visit(annotation); + } + } + } + /** * Add all packages that directly or indirectly depend on the providedPackages @@ -792,6 +1060,22 @@ private void addDependencyWurstFiles(Set result, File file) { } } + private boolean isJassFile(CompilationUnit cu) { + return cu.getCuInfo().getFile().endsWith(".j"); + } + + private boolean isJassFile(WFile file) { + return file.toString().endsWith(".j"); + } + + private void clearPendingJassChanges(Collection files) { + for (WFile file : files) { + if (isJassFile(file)) { + pendingJassFunctionChanges.remove(file); + } + } + } + private WFile wFile(CompilationUnit cu) { return compilationunitFile.computeIfAbsent(cu, c -> WFile.create(cu.getCuInfo().getFile())); } diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ModelManagerTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ModelManagerTests.java index 518b68cf3..404501d77 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ModelManagerTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/ModelManagerTests.java @@ -26,16 +26,25 @@ import java.io.File; import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.nio.file.Files; +import java.util.Collection; +import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.MatcherAssert.assertThat; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; public class ModelManagerTests { @@ -280,6 +289,131 @@ private String string(String... lines) { } + @Test + public void jassFunctionChangeTargetsOnlyUsers() throws Exception { + JassFixture fixture = setupJassFixture("testProjectJassDeps1", baseJass()); + + String updated = string( + "function Foo takes nothing returns nothing", + " call BJDebugMsg(\"foo2\")", + "endfunction", + "", + "function Bar takes nothing returns nothing", + " call BJDebugMsg(\"bar\")", + "endfunction" + ); + + fixture.manager.syncCompilationUnitContent(fixture.jassFile, updated); + CompilationUnit jassCu = fixture.manager.getCompilationUnit(fixture.jassFile); + Set updatedFiles = calculateUpdatedFiles(fixture.manager, jassCu); + + assertEquals(updatedFiles, ImmutableSet.of(fixture.jassFile, fixture.fileA)); + } + + @Test + public void jassCommentChangeDoesNotFanOut() throws Exception { + JassFixture fixture = setupJassFixture("testProjectJassDeps2", baseJass()); + + String updated = baseJass() + "\n// comment"; + fixture.manager.syncCompilationUnitContent(fixture.jassFile, updated); + CompilationUnit jassCu = fixture.manager.getCompilationUnit(fixture.jassFile); + Set updatedFiles = calculateUpdatedFiles(fixture.manager, jassCu); + + assertEquals(updatedFiles, ImmutableSet.of(fixture.jassFile)); + } + + @Test + public void jassFunctionRemovalTouchesConsumers() throws Exception { + JassFixture fixture = setupJassFixture("testProjectJassDeps3", baseJass()); + + String updated = string( + "function Foo takes nothing returns nothing", + " call BJDebugMsg(\"foo\")", + "endfunction" + ); + + fixture.manager.syncCompilationUnitContent(fixture.jassFile, updated); + CompilationUnit jassCu = fixture.manager.getCompilationUnit(fixture.jassFile); + Set updatedFiles = calculateUpdatedFiles(fixture.manager, jassCu); + + assertEquals(updatedFiles, ImmutableSet.of(fixture.jassFile, fixture.fileB)); + } + + private String baseJass() { + return string( + "function Foo takes nothing returns nothing", + " call BJDebugMsg(\"foo\")", + "endfunction", + "", + "function Bar takes nothing returns nothing", + " call BJDebugMsg(\"bar\")", + "endfunction" + ); + } + + private JassFixture setupJassFixture(String folderName, String jassContent) throws IOException { + File projectFolder = new File("./temp/" + folderName + "/"); + File wurstFolder = new File(projectFolder, "wurst"); + newCleanFolder(wurstFolder); + + WFile jassFile = WFile.create(new File(wurstFolder, "functions.j")); + WFile fileA = WFile.create(new File(wurstFolder, "A.wurst")); + WFile fileB = WFile.create(new File(wurstFolder, "B.wurst")); + WFile fileWurst = WFile.create(new File(wurstFolder, "Wurst.wurst")); + + writeFile(jassFile, jassContent); + writeFile(fileA, string( + "package A", + "public function useFoo()", + " Foo()" + )); + writeFile(fileB, string( + "package B", + "public function useBar()", + " Bar()" + )); + writeFile(fileWurst, "package Wurst\n"); + + ModelManagerImpl manager = new ModelManagerImpl(projectFolder, new BufferManager()); + keepErrorsInMap(manager); + manager.buildProject(); + + return new JassFixture(manager, jassFile, fileA, fileB, fileWurst); + } + + private Set calculateUpdatedFiles(ModelManagerImpl manager, CompilationUnit changedCu) + throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { + Method method = ModelManagerImpl.class.getDeclaredMethod( + "calculateCUsToUpdate", Collection.class, Set.class, WurstModel.class); + method.setAccessible(true); + @SuppressWarnings("unchecked") + Set result = (Set) method.invoke( + manager, + Collections.singletonList(changedCu), + Collections.emptySet(), + manager.getModel()); + return result.stream() + .map(cu -> WFile.create(cu.getCuInfo().getFile())) + .collect(Collectors.toCollection(HashSet::new)); + } + + private static final class JassFixture { + final ModelManagerImpl manager; + final WFile jassFile; + final WFile fileA; + final WFile fileB; + final WFile fileWurst; + + private JassFixture(ModelManagerImpl manager, WFile jassFile, WFile fileA, WFile fileB, WFile fileWurst) { + this.manager = manager; + this.jassFile = jassFile; + this.fileA = fileA; + this.fileB = fileB; + this.fileWurst = fileWurst; + } + } + + @Test public void changeModule() throws IOException { File projectFolder = new File("./temp/testProject2/");