From 6f6b5e0d3c576209db46732a900bd10b8a46e58f Mon Sep 17 00:00:00 2001 From: Frotty Date: Wed, 4 Mar 2026 11:45:57 +0100 Subject: [PATCH 1/2] fix incremental syncing of compilation units --- .../languageserver/DebouncingTimer.java | 7 + .../languageserver/LanguageWorker.java | 57 ++- .../wurstio/languageserver/ModelManager.java | 5 + .../languageserver/ModelManagerImpl.java | 35 +- .../languageserver/LanguageWorkerTest.java | 329 ++++++++++++++++++ .../wurstscript/tests/ModelManagerTests.java | 24 ++ 6 files changed, 447 insertions(+), 10 deletions(-) create mode 100644 de.peeeq.wurstscript/src/test/java/de/peeeq/wurstio/languageserver/LanguageWorkerTest.java diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/DebouncingTimer.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/DebouncingTimer.java index 402525d27..6c9c15a08 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/DebouncingTimer.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/DebouncingTimer.java @@ -41,6 +41,13 @@ public synchronized void start(Duration d) { }, d.toMillis(), TimeUnit.MILLISECONDS); } + /** marks timer as ready immediately and triggers action */ + public synchronized void triggerNow() { + stop(); + isReady = true; + action.run(); + } + diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/LanguageWorker.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/LanguageWorker.java index 7b4784482..3fb845696 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/LanguageWorker.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/LanguageWorker.java @@ -52,6 +52,7 @@ public void setRootPath(WFile rootPath) { private final Object lock = new Object(); private ModelManager.Changes changesToReconcile = ModelManager.Changes.empty(); + private boolean reconcileNowRequested = false; private final DebouncingTimer packagesToReconcileTimer = new DebouncingTimer(() -> { synchronized (lock) { lock.notify(); @@ -112,6 +113,17 @@ public String toString() { } } + class FileSystemUpdated extends PendingChange { + public FileSystemUpdated(WFile filename) { + super(filename); + } + + @Override + public String toString() { + return "FileSystemUpdated(" + getFilename() + ")"; + } + } + class FileDeleted extends PendingChange { public FileDeleted(WFile filename) { @@ -182,6 +194,12 @@ private Workitem getNextWorkItem() { // cannot do anything useful at the moment WLogger.info("LanguageWorker is waiting for init ... "); } + } else if (reconcileNowRequested && !changesToReconcile.isEmpty() && changes.isEmpty()) { + packagesToReconcileTimer.stop(); + ModelManager.Changes changes = changesToReconcile; + changesToReconcile = ModelManager.Changes.empty(); + reconcileNowRequested = false; + return new Workitem("reconcile files (save)", () -> modelManager.reconcile(changes)); } else if (!userRequests.isEmpty()) { UserRequest req = userRequests.remove(); return new Workitem(req.toString(), () -> req.run(modelManager)); @@ -191,11 +209,25 @@ private Workitem getNextWorkItem() { return new Workitem(change.toString(), () -> { ModelManager.Changes affected = null; if (isWurstDependencyFile(change)) { - if (!(change instanceof FileReconcile)) { - modelManager.clean(); + if (change instanceof FileReconcile) { + FileReconcile fr = (FileReconcile) change; + affected = modelManager.syncCompilationUnitContent(fr.getFilename(), fr.getContents()); + } else if (change instanceof FileSystemUpdated || change instanceof FileDeleted) { + // Dependency roots may have changed (e.g. grill install), refresh and sync incrementally. + modelManager.refreshDependencies(); + if (change instanceof FileDeleted) { + affected = modelManager.removeCompilationUnit(change.getFilename()); + } else { + affected = modelManager.syncCompilationUnit(change.getFilename()); + } + } else { + // Editor-triggered updates (save/close) use the normal incremental path. + affected = modelManager.syncCompilationUnit(change.getFilename()); } } else if (change instanceof FileDeleted) { affected = modelManager.removeCompilationUnit(change.getFilename()); + } else if (change instanceof FileSystemUpdated) { + affected = modelManager.syncCompilationUnit(change.getFilename()); } else if (change instanceof FileUpdated) { affected = modelManager.syncCompilationUnit(change.getFilename()); } else if (change instanceof FileReconcile) { @@ -214,6 +246,7 @@ private Workitem getNextWorkItem() { packagesToReconcileTimer.stop(); ModelManager.Changes changes = changesToReconcile; changesToReconcile = ModelManager.Changes.empty(); + reconcileNowRequested = false; return new Workitem("reconcile files", () -> { modelManager.reconcile(changes); @@ -223,7 +256,11 @@ private Workitem getNextWorkItem() { } private boolean isWurstDependencyFile(PendingChange change) { - String uri = change.getFilename().getUriString().replace('\\', '/'); + return isWurstDependencyFile(change.getFilename()); + } + + private boolean isWurstDependencyFile(WFile file) { + String uri = file.getUriString().replace('\\', '/'); return uri.contains("/_build/dependencies/"); } @@ -262,13 +299,18 @@ private void log(String s) { public void handleFileChanged(DidChangeWatchedFilesParams params) { synchronized (lock) { for (FileEvent fileEvent : params.getChanges()) { - bufferManager.handleFileChange(fileEvent); - WFile file = WFile.create(fileEvent.getUri()); + boolean isOpenInEditor = bufferManager.getTextDocumentVersion(file) >= 0; + // For open documents incremental didChange is authoritative. + // Ignore watcher changed/created events to avoid clobbering in-memory state. + if (isOpenInEditor && fileEvent.getType() != FileChangeType.Deleted) { + continue; + } + bufferManager.handleFileChange(fileEvent); if (fileEvent.getType() == FileChangeType.Deleted) { changes.put(file, new FileDeleted(file)); } else { - changes.put(file, new FileUpdated(file)); + changes.put(file, new FileSystemUpdated(file)); } } lock.notifyAll(); @@ -306,7 +348,10 @@ public void handleClose(DidCloseTextDocumentParams params) { public void handleSave(DidSaveTextDocumentParams params) { synchronized (lock) { WFile file = WFile.create(params.getTextDocument().getUri()); + reconcileNowRequested = true; changes.put(file, new FileUpdated(file)); + // Save should flush diagnostics quickly instead of waiting for debounce. + packagesToReconcileTimer.triggerNow(); lock.notifyAll(); } } diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManager.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManager.java index 9cbe61f8b..a582a5800 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManager.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstio/languageserver/ModelManager.java @@ -29,6 +29,11 @@ public interface ModelManager { void buildProject(); + /** + * refresh discovered dependency roots (e.g. _build/dependencies after grill install) + */ + void refreshDependencies(); + Changes syncCompilationUnit(WFile changedFilePath); Changes syncCompilationUnitContent(WFile filename, String contents); 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 44e2933ef..622f3cc2f 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 @@ -179,6 +179,11 @@ private void readDependencies() { WurstCompilerJassImpl.addDependenciesFromFolder(projectPath, dependencies); } + @Override + public void refreshDependencies() { + readDependencies(); + } + private String getCanonicalPath(File f) { try { return f.getCanonicalPath(); @@ -477,6 +482,13 @@ private void replaceCompilationUnit(WFile filename) { @Override public Changes syncCompilationUnitContent(WFile filename, String contents) { WLogger.debug("sync contents for " + filename); + int newHash = contentHash(contents); + Integer oldHash = fileHashcodes.get(filename); + CompilationUnit existing = getCompilationUnit(filename); + if (oldHash != null && oldHash == newHash && existing != null) { + // No content change and CU still present -> skip expensive reconcile/typecheck work. + return Changes.empty(); + } Set oldPackages = declaredPackages(filename); replaceCompilationUnit(filename, contents, false); return new Changes(io.vavr.collection.HashSet.of(filename), oldPackages); @@ -509,8 +521,16 @@ public CompilationUnit replaceCompilationUnitContent(WFile filename, String cont @Override public Changes syncCompilationUnit(WFile f) { WLogger.debug("syncCompilationUnit File " + f); + String contents = bufferManager.getBuffer(f); + int newHash = contentHash(contents); + Integer oldHash = fileHashcodes.get(f); + CompilationUnit existing = getCompilationUnit(f); + if (oldHash != null && oldHash == newHash && existing != null) { + WLogger.trace(() -> "syncCompilationUnit no-op for " + f); + return Changes.empty(); + } Set oldPackages = declaredPackages(f); - replaceCompilationUnit(f); + replaceCompilationUnit(f, contents, true); WLogger.debug("replaced file " + f); WurstGui gui = new WurstGuiLogger(); doTypeCheckPartial(gui, ImmutableList.of(f), oldPackages); @@ -521,9 +541,10 @@ private CompilationUnit replaceCompilationUnit(WFile filename, String contents, if (!isInWurstFolder(filename) && !isAlreadyLoaded(filename)) { return null; } + int newHash = contentHash(contents); if (fileHashcodes.containsKey(filename)) { int oldHash = fileHashcodes.get(filename); - if (oldHash == contents.hashCode()) { + if (oldHash == newHash) { CompilationUnit existing = getCompilationUnit(filename); if (existing != null) { // no change @@ -533,7 +554,7 @@ private CompilationUnit replaceCompilationUnit(WFile filename, String contents, // Stale hash cache after remove/move; CU is gone, so reparse. WLogger.debug("CU hash unchanged but model entry missing for " + filename + ", reparsing."); } else { - WLogger.debug("CU changed. oldHash = " + oldHash + " == " + contents.hashCode()); + WLogger.debug("CU changed. oldHash = " + oldHash + " == " + newHash); } } @@ -543,7 +564,7 @@ private CompilationUnit replaceCompilationUnit(WFile filename, String contents, CompilationUnit cu = c.parse(filename.toString(), new StringReader(contents)); cu.getCuInfo().setFile(filename.toString()); updateModel(cu, gui); - fileHashcodes.put(filename, contents.hashCode()); + fileHashcodes.put(filename, newHash); if (reportErrors) { if (gui.getErrorCount() > 0) { WLogger.debug("found " + gui.getErrorCount() + " errors in file " + filename); @@ -832,4 +853,10 @@ private boolean isAlreadyLoaded(WFile file) { public File getProjectPath() { return projectPath; } + + private int contentHash(String s) { + // Normalize line endings to avoid false "changed" signals between editor buffers and disk text. + String normalized = s.replace("\r\n", "\n").replace('\r', '\n'); + return normalized.hashCode(); + } } diff --git a/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstio/languageserver/LanguageWorkerTest.java b/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstio/languageserver/LanguageWorkerTest.java new file mode 100644 index 000000000..27d8aa2ba --- /dev/null +++ b/de.peeeq.wurstscript/src/test/java/de/peeeq/wurstio/languageserver/LanguageWorkerTest.java @@ -0,0 +1,329 @@ +package de.peeeq.wurstio.languageserver; + +import de.peeeq.wurstscript.ast.CompilationUnit; +import de.peeeq.wurstscript.ast.WurstModel; +import de.peeeq.wurstscript.attributes.CompileError; +import org.eclipse.lsp4j.*; +import org.testng.annotations.Test; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + +public class LanguageWorkerTest { + + @Test + public void watcherChangedForOpenFileIsIgnored() throws Exception { + Path tmp = Files.createTempDirectory("wurst-lw-open"); + File file = tmp.resolve("wurst").resolve("Main.wurst").toFile(); + //noinspection ResultOfMethodCallIgnored + file.getParentFile().mkdirs(); + Files.writeString(file.toPath(), "package Main\n"); + WFile wFile = WFile.create(file); + + LanguageWorker worker = new LanguageWorker(); + CountingModelManager mm = new CountingModelManager(tmp.toFile()); + worker.modelManager = mm; + + try { + worker.handleOpen(new DidOpenTextDocumentParams(new TextDocumentItem( + wFile.getUriString(), + "wurst", + 1, + "package Main\n" + ))); + assertTrue(waitUntil(() -> mm.syncContentCalls.get() >= 1, 2000), "open should trigger reconcile"); + + mm.syncFileCalls.set(0); + + worker.handleFileChanged(new DidChangeWatchedFilesParams(Collections.singletonList( + new FileEvent(wFile.getUriString(), FileChangeType.Changed) + ))); + + Thread.sleep(250); + assertEquals(mm.syncFileCalls.get(), 0, "watcher changed event must not resync open files"); + } finally { + worker.stop(); + } + } + + @Test + public void dependencyDidChangeUsesIncrementalSync() throws Exception { + Path tmp = Files.createTempDirectory("wurst-lw-dep-reconcile"); + File file = tmp.resolve("_build").resolve("dependencies").resolve("depA").resolve("wurst").resolve("Lib.wurst").toFile(); + //noinspection ResultOfMethodCallIgnored + file.getParentFile().mkdirs(); + Files.writeString(file.toPath(), "package Lib\n"); + WFile wFile = WFile.create(file); + + LanguageWorker worker = new LanguageWorker(); + CountingModelManager mm = new CountingModelManager(tmp.toFile()); + worker.modelManager = mm; + + try { + worker.handleOpen(new DidOpenTextDocumentParams(new TextDocumentItem( + wFile.getUriString(), + "wurst", + 1, + "package Lib\n" + ))); + + assertTrue(waitUntil(() -> mm.syncContentCalls.get() >= 1, 2000), "dependency open should sync content"); + + VersionedTextDocumentIdentifier td = new VersionedTextDocumentIdentifier(wFile.getUriString(), 2); + TextDocumentContentChangeEvent ch = new TextDocumentContentChangeEvent(); + ch.setText("package Lib\n// edit\n"); + worker.handleChange(new DidChangeTextDocumentParams(td, Collections.singletonList(ch))); + + assertTrue(waitUntil(() -> mm.syncContentCalls.get() >= 1, 2000), "dependency didChange should sync content"); + assertEquals(mm.cleanCalls.get(), 0, "dependency didChange must not clean model"); + } finally { + worker.stop(); + } + } + + @Test + public void dependencyWatcherChangedSyncsIncrementally() throws Exception { + Path tmp = Files.createTempDirectory("wurst-lw-dep-changed"); + File file = tmp.resolve("_build").resolve("dependencies").resolve("depB").resolve("wurst").resolve("Lib.wurst").toFile(); + //noinspection ResultOfMethodCallIgnored + file.getParentFile().mkdirs(); + Files.writeString(file.toPath(), "package Lib\n"); + WFile wFile = WFile.create(file); + + LanguageWorker worker = new LanguageWorker(); + CountingModelManager mm = new CountingModelManager(tmp.toFile()); + worker.modelManager = mm; + + try { + worker.handleFileChanged(new DidChangeWatchedFilesParams(Collections.singletonList( + new FileEvent(wFile.getUriString(), FileChangeType.Changed) + ))); + + assertTrue(waitUntil(() -> mm.refreshDependencyCalls.get() >= 1, 2000), "dependency watcher changes should refresh dependency roots"); + assertTrue(waitUntil(() -> mm.syncFileCalls.get() >= 1, 2000), "dependency watcher changes should sync changed file"); + assertEquals(mm.cleanCalls.get(), 0, "dependency watcher changes should not clean"); + assertEquals(mm.buildCalls.get(), 0, "dependency watcher changes should not full rebuild"); + } finally { + worker.stop(); + } + } + + @Test + public void closingDependencyFileDoesNotTriggerRebuild() throws Exception { + Path tmp = Files.createTempDirectory("wurst-lw-dep-close"); + File file = tmp.resolve("_build").resolve("dependencies").resolve("depC").resolve("wurst").resolve("Lib.wurst").toFile(); + //noinspection ResultOfMethodCallIgnored + file.getParentFile().mkdirs(); + Files.writeString(file.toPath(), "package Lib\n"); + WFile wFile = WFile.create(file); + + LanguageWorker worker = new LanguageWorker(); + CountingModelManager mm = new CountingModelManager(tmp.toFile()); + worker.modelManager = mm; + + try { + worker.handleOpen(new DidOpenTextDocumentParams(new TextDocumentItem( + wFile.getUriString(), + "wurst", + 1, + "package Lib\n" + ))); + assertTrue(waitUntil(() -> mm.syncContentCalls.get() >= 1, 2000), "open should sync content"); + + worker.handleClose(new DidCloseTextDocumentParams(new TextDocumentIdentifier(wFile.getUriString()))); + + Thread.sleep(250); + assertEquals(mm.cleanCalls.get(), 0, "closing dependency file must not clean"); + assertEquals(mm.buildCalls.get(), 0, "closing dependency file must not rebuild"); + assertTrue(mm.syncFileCalls.get() <= 1, "closing dependency file should only use normal sync path"); + } finally { + worker.stop(); + } + } + + @Test + public void closingCoreBuildJassFileDoesNotTriggerUpdate() throws Exception { + Path tmp = Files.createTempDirectory("wurst-lw-commonj-close"); + File file = tmp.resolve("_build").resolve("common.j").toFile(); + //noinspection ResultOfMethodCallIgnored + file.getParentFile().mkdirs(); + Files.writeString(file.toPath(), "globals\nendglobals\n"); + WFile wFile = WFile.create(file); + + LanguageWorker worker = new LanguageWorker(); + CountingModelManager mm = new CountingModelManager(tmp.toFile()); + worker.modelManager = mm; + + try { + worker.handleOpen(new DidOpenTextDocumentParams(new TextDocumentItem( + wFile.getUriString(), + "jass", + 1, + "globals\nendglobals\n" + ))); + assertTrue(waitUntil(() -> mm.syncContentCalls.get() >= 1, 2000), "opening common.j should sync content"); + + worker.handleClose(new DidCloseTextDocumentParams(new TextDocumentIdentifier(wFile.getUriString()))); + + Thread.sleep(250); + assertTrue(mm.syncFileCalls.get() <= 1, "closing common.j should only use normal sync path"); + assertEquals(mm.cleanCalls.get(), 0, "closing common.j must not clean"); + assertEquals(mm.buildCalls.get(), 0, "closing common.j must not rebuild"); + } finally { + worker.stop(); + } + } + + @Test + public void saveTriggersImmediateReconcile() throws Exception { + Path tmp = Files.createTempDirectory("wurst-lw-save-reconcile"); + File file = tmp.resolve("wurst").resolve("Main.wurst").toFile(); + //noinspection ResultOfMethodCallIgnored + file.getParentFile().mkdirs(); + Files.writeString(file.toPath(), "package Main\n"); + WFile wFile = WFile.create(file); + + LanguageWorker worker = new LanguageWorker(); + CountingModelManager mm = new CountingModelManager(tmp.toFile()); + worker.modelManager = mm; + + try { + worker.handleOpen(new DidOpenTextDocumentParams(new TextDocumentItem( + wFile.getUriString(), "wurst", 1, "package Main\n" + ))); + assertTrue(waitUntil(() -> mm.syncContentCalls.get() >= 1, 2000), "open should sync content"); + + VersionedTextDocumentIdentifier td = new VersionedTextDocumentIdentifier(wFile.getUriString(), 2); + TextDocumentContentChangeEvent ch = new TextDocumentContentChangeEvent(); + ch.setText("package Main\n// edit\n"); + worker.handleChange(new DidChangeTextDocumentParams(td, Collections.singletonList(ch))); + assertTrue(waitUntil(() -> mm.syncContentCalls.get() >= 2, 2000), "change should sync content"); + + mm.reconcileCalls.set(0); + long saveStart = System.currentTimeMillis(); + worker.handleSave(new DidSaveTextDocumentParams(new TextDocumentIdentifier(wFile.getUriString()))); + + assertTrue(waitUntil(() -> mm.reconcileCalls.get() >= 1, 800), "save should trigger fast reconcile"); + long elapsed = System.currentTimeMillis() - saveStart; + assertTrue(elapsed < 1000, "save reconcile should not wait for long debounce"); + } finally { + worker.stop(); + } + } + + private static boolean waitUntil(BooleanSupplier condition, long timeoutMs) throws InterruptedException { + long start = System.currentTimeMillis(); + while (System.currentTimeMillis() - start < timeoutMs) { + if (condition.getAsBoolean()) { + return true; + } + Thread.sleep(20); + } + return condition.getAsBoolean(); + } + + private static class CountingModelManager implements ModelManager { + final AtomicInteger cleanCalls = new AtomicInteger(); + final AtomicInteger buildCalls = new AtomicInteger(); + final AtomicInteger syncFileCalls = new AtomicInteger(); + final AtomicInteger syncContentCalls = new AtomicInteger(); + final AtomicInteger refreshDependencyCalls = new AtomicInteger(); + final AtomicInteger reconcileCalls = new AtomicInteger(); + final File projectPath; + + private CountingModelManager(File projectPath) { + this.projectPath = projectPath; + } + + @Override + public Changes removeCompilationUnit(WFile filename) { + return Changes.empty(); + } + + @Override + public void clean() { + cleanCalls.incrementAndGet(); + } + + @Override + public List getParseErrors() { + return Collections.emptyList(); + } + + @Override + public void onCompilationResult(Consumer f) { + } + + @Override + public void buildProject() { + buildCalls.incrementAndGet(); + } + + @Override + public void refreshDependencies() { + refreshDependencyCalls.incrementAndGet(); + } + + @Override + public Changes syncCompilationUnit(WFile changedFilePath) { + syncFileCalls.incrementAndGet(); + return Changes.empty(); + } + + @Override + public Changes syncCompilationUnitContent(WFile filename, String contents) { + syncContentCalls.incrementAndGet(); + return new Changes(Collections.singletonList(filename), Collections.emptyList()); + } + + @Override + public CompilationUnit replaceCompilationUnitContent(WFile filename, String buffer, boolean reportErrors) { + return null; + } + + @Override + public Set getDependencyWurstFiles() { + return Collections.emptySet(); + } + + @Override + public CompilationUnit getCompilationUnit(WFile filename) { + return null; + } + + @Override + public WurstModel getModel() { + return null; + } + + @Override + public boolean hasErrors() { + return false; + } + + @Override + public File getProjectPath() { + return projectPath; + } + + @Override + public String getFirstErrorDescription() { + return ""; + } + + @Override + public void reconcile(Changes changes) { + reconcileCalls.incrementAndGet(); + } + } +} 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 f19779753..2e1a6855c 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 @@ -941,6 +941,30 @@ public void runmapPurge_onlyKeepsWar3MapFromProjectWurstFolder() throws Exceptio assertEquals(manager.getCompilationUnit(fileDependencyWar3Map), null, "dependency war3map.j must not be retained implicitly"); } + @Test + public void syncCompilationUnitContent_noopContentProducesNoChanges() throws Exception { + File projectFolder = new File("./temp/testProject_sync_noop/"); + File wurstFolder = new File(projectFolder, "wurst"); + newCleanFolder(wurstFolder); + + WFile fileWurst = WFile.create(new File(wurstFolder, "Wurst.wurst")); + WFile fileMain = WFile.create(new File(wurstFolder, "Main.wurst")); + + writeFile(fileWurst, "package Wurst\n"); + writeFile(fileMain, string( + "package Main", + "init", + " skip" + )); + + ModelManagerImpl manager = new ModelManagerImpl(projectFolder, new BufferManager()); + manager.buildProject(); + + String sameContent = Files.readString(fileMain.getFile().toPath()); + ModelManager.Changes changes = manager.syncCompilationUnitContent(fileMain, sameContent); + assertEquals(changes.isEmpty(), true, "unchanged sync should not trigger reconcile work"); + } + private void purgeUnimportedFiles_likeRunMap(WurstModel model, ModelManagerImpl manager) { java.util.Set keep = model.stream() .filter(cu -> isInProjectWurstFolder_likeRunMap(cu.getCuInfo().getFile(), manager) From f587010c020961f91305f7936aed23ef571c8c57 Mon Sep 17 00:00:00 2001 From: Frotty Date: Wed, 4 Mar 2026 11:58:42 +0100 Subject: [PATCH 2/2] Update ModelManagerImpl.java --- .../wurstio/languageserver/ModelManagerImpl.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 622f3cc2f..0520bd019 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 @@ -521,7 +521,19 @@ public CompilationUnit replaceCompilationUnitContent(WFile filename, String cont @Override public Changes syncCompilationUnit(WFile f) { WLogger.debug("syncCompilationUnit File " + f); - String contents = bufferManager.getBuffer(f); + String contents; + try { + File file = f.getFile(); + if (!file.exists()) { + removeCompilationUnit(f); + return Changes.empty(); + } + contents = Files.toString(file, Charsets.UTF_8); + bufferManager.updateFile(WFile.create(file), contents); + } catch (IOException e) { + WLogger.severe(e); + throw new ModelManagerException(e); + } int newHash = contentHash(contents); Integer oldHash = fileHashcodes.get(f); CompilationUnit existing = getCompilationUnit(f);