| author | kwannheden | 2009-08-21 03:42:30 (EDT) |
|---|---|---|
| committer | sefftinge | 2009-08-21 03:42:30 (EDT) |
| commit | 3a690efefcd739c1fbff5b8562f17c4c4e2d2601 (patch) (side-by-side diff) | |
| tree | 320b06454dec316e756e07cc926f472e676831a7 | |
| parent | 9815f1e445ecaa147ed76e8afd9728abbf17a14f (diff) | |
| download | org.eclipse.xtext-3a690efefcd739c1fbff5b8562f17c4c4e2d2601.zip org.eclipse.xtext-3a690efefcd739c1fbff5b8562f17c4c4e2d2601.tar.gz org.eclipse.xtext-3a690efefcd739c1fbff5b8562f17c4c4e2d2601.tar.bz2 | |
Feature: propagate model changes to editor - https://bugs.eclipse.org/bugs/show_bug.cgi?id=265097
9 files changed, 515 insertions, 4 deletions
diff --git a/plugins/org.eclipse.xtext.ui.core/META-INF/MANIFEST.MF b/plugins/org.eclipse.xtext.ui.core/META-INF/MANIFEST.MF index fe42442..70ad1e3 100644 --- a/plugins/org.eclipse.xtext.ui.core/META-INF/MANIFEST.MF +++ b/plugins/org.eclipse.xtext.ui.core/META-INF/MANIFEST.MF @@ -26,6 +26,7 @@ Export-Package: org.eclipse.xtext.ui.core, org.eclipse.xtext.ui.core.editor.formatting, org.eclipse.xtext.ui.core.editor.handler, org.eclipse.xtext.ui.core.editor.model, + org.eclipse.xtext.ui.core.editor.model.edit, org.eclipse.xtext.ui.core.editor.preferences, org.eclipse.xtext.ui.core.editor.preferences.fields, org.eclipse.xtext.ui.core.editor.reconciler, diff --git a/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/XtextDocument.java b/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/XtextDocument.java index f1e36ef..c7bb986 100644 --- a/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/XtextDocument.java +++ b/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/XtextDocument.java @@ -8,6 +8,7 @@ *******************************************************************************/ package org.eclipse.xtext.ui.core.editor.model; +import java.io.IOException; import java.util.HashMap; import java.util.Map; @@ -206,7 +207,7 @@ public class XtextDocument extends Document implements IXtextDocument { } @Override - protected void beforeReadOnly(XtextResource res, org.eclipse.xtext.concurrent.IUnitOfWork<?, XtextResource> work) { + protected void beforeReadOnly(XtextResource res, IUnitOfWork<?, XtextResource> work) { if (log.isDebugEnabled()) log.debug("read - " + Thread.currentThread().getName()); updateContentBeforeRead(); @@ -219,16 +220,30 @@ public class XtextDocument extends Document implements IXtextDocument { } @Override - protected void afterReadOnly(XtextResource res, Object result, org.eclipse.xtext.concurrent.IUnitOfWork<?, XtextResource> work) { + protected void afterReadOnly(XtextResource res, Object result, IUnitOfWork<?, XtextResource> work) { ensureThatStateIsNotReturned(result, work); } @Override - protected void afterModify(XtextResource res, Object result, org.eclipse.xtext.concurrent.IUnitOfWork<?, XtextResource> work) { + protected void afterModify(XtextResource res, Object result, IUnitOfWork<?, XtextResource> work) { ensureThatStateIsNotReturned(result, work); notifyModelListeners(resource); } + @Override + public <T> T modify(IUnitOfWork<T, XtextResource> work) { + try { + return super.modify(work); + } catch (RuntimeException e) { + try { + getState().reparse(get()); + } + catch (IOException ioe) { + } + throw e; + } + } + public <T> T process(IUnitOfWork<T, XtextResource> transaction) { if (transaction != null) { readLock.unlock(); diff --git a/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/edit/DefaultDocumentEditor.java b/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/edit/DefaultDocumentEditor.java new file mode 100644 index 0000000..2b12bf5 --- a/dev/null +++ b/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/edit/DefaultDocumentEditor.java @@ -0,0 +1,58 @@ +/******************************************************************************* + * Copyright (c) 2009 itemis AG (http://www.itemis.eu) and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.eclipse.xtext.ui.core.editor.model.edit; + +import org.eclipse.text.edits.TextEdit; +import org.eclipse.xtext.concurrent.IUnitOfWork; +import org.eclipse.xtext.resource.XtextResource; +import org.eclipse.xtext.ui.core.editor.model.IXtextDocument; + +import com.google.inject.Inject; + +/** + * @author Knut Wannheden - Initial contribution and API + */ +public class DefaultDocumentEditor implements IDocumentEditor { + + @Inject + private ITextEditComposer composer; + + private final class ReconcilingUnitOfWork<T> implements IUnitOfWork<T, XtextResource> { + + private final IUnitOfWork<T, XtextResource> work; + private final IXtextDocument document; + + private ReconcilingUnitOfWork(IUnitOfWork<T, XtextResource> work, IXtextDocument document) { + this.work = work; + this.document = document; + } + + public T exec(XtextResource state) throws Exception { + composer.beginRecording(state); + T result = work.exec(state); + final TextEdit edit = composer.endRecording(); + if (edit != null) { + String original = document.get(); + try { + edit.apply(document); + } + catch (Exception e) { + document.set(original); + throw new RuntimeException(e); + } + } + return result; + } + } + + public <T> T process(final IUnitOfWork<T, XtextResource> work, final IXtextDocument document) { + IUnitOfWork<T, XtextResource> reconcilingUnitOfWork = new ReconcilingUnitOfWork<T>(work, document); + return document.modify(reconcilingUnitOfWork); + } + +} diff --git a/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/edit/DefaultTextEditComposer.java b/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/edit/DefaultTextEditComposer.java new file mode 100644 index 0000000..c436074 --- a/dev/null +++ b/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/edit/DefaultTextEditComposer.java @@ -0,0 +1,163 @@ +/******************************************************************************* + * Copyright (c) 2009 itemis AG (http://www.itemis.eu) and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.eclipse.xtext.ui.core.editor.model.edit; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; + +import org.eclipse.emf.common.notify.Notification; +import org.eclipse.emf.ecore.EObject; +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.emf.ecore.util.EContentAdapter; +import org.eclipse.emf.ecore.util.EcoreUtil; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.text.edits.ReplaceEdit; +import org.eclipse.text.edits.TextEdit; +import org.eclipse.xtext.parsetree.CompositeNode; +import org.eclipse.xtext.parsetree.NodeAdapter; +import org.eclipse.xtext.parsetree.NodeUtil; +import org.eclipse.xtext.parsetree.reconstr.SerializerUtil; + +import com.google.common.collect.Lists; +import com.google.inject.Inject; + +/** + * @author Knut Wannheden - Initial contribution and API + */ +public class DefaultTextEditComposer extends EContentAdapter implements ITextEditComposer { + + @Inject + private SerializerUtil serializerUtil; + + private Resource resource; + private int resourceSize; + private boolean resourceChanged; + + private Collection<EObject> modifiedObjects = new LinkedHashSet<EObject>(); + + private boolean recording = false; + + @Override + public void notifyChanged(Notification notification) { + super.notifyChanged(notification); + + if (!doRecord(notification)) + return; + + if (notification.getNotifier() instanceof EObject) { + recordObjectModification((EObject) notification.getNotifier()); + } + else if (notification.getNotifier() instanceof Resource) { + recordResourceModification((Resource) notification.getNotifier()); + } + } + + protected void recordObjectModification(EObject obj) { + if (obj.eResource() == null || obj.eResource() != resource) + getModifiedObjects().remove(obj); + else + getModifiedObjects().add(obj); + } + + protected void recordResourceModification(Resource notifier) { + resourceChanged = true; + } + + protected Collection<EObject> getModifiedObjects() { + return modifiedObjects; + } + + protected boolean doRecord(Notification notification) { + if (!recording || notification.isTouch()) + return false; + + switch (notification.getEventType()) { + case Notification.ADD: + case Notification.ADD_MANY: + case Notification.MOVE: + case Notification.REMOVE: + case Notification.REMOVE_MANY: + case Notification.SET: + case Notification.UNSET: + return true; + default: + return false; + } + } + + public void beginRecording(Resource newResource) { + if (newResource != resource) { + if (resource != null) + resource.eAdapters().remove(this); + newResource.eAdapters().add(this); + resource = newResource; + } + if (resource.getContents().isEmpty()) { + resourceSize = 0; + } + else { + final EObject root = resource.getContents().get(0); + resourceSize = NodeUtil.getNodeAdapter(root).getParserNode().getTotalLength(); + } + recording = true; + } + + public TextEdit endRecording() { + recording = false; + TextEdit textEdit = getTextEdit(); + + getModifiedObjects().clear(); + resourceChanged = false; + + return textEdit; + } + + public TextEdit getTextEdit() { + TextEdit result = null; + + if (resourceChanged) { + String text = resource.getContents().isEmpty() ? "" : serializerUtil.serialize( + resource.getContents().get(0)).trim(); + result = new ReplaceEdit(0, resourceSize, text); + } + else { + final Collection<EObject> modifiedObjects = getModifiedObjects(); + if (!modifiedObjects.isEmpty()) { + List<TextEdit> edits = getObjectEdits(); + if (edits.size() == 1) + result = edits.get(0); + else { + result = new MultiTextEdit(); + for (TextEdit edit : edits) { + result.addChild(edit); + } + } + } + } + + return result; + } + + private List<TextEdit> getObjectEdits() { + final Collection<EObject> modifiedObjects = getModifiedObjects(); + Collection<EObject> topLevelObjects = EcoreUtil.filterDescendants(modifiedObjects); + List<TextEdit> edits = Lists.newArrayList(); + + for (EObject eObject : topLevelObjects) { + NodeAdapter nodeAdapter = NodeUtil.getNodeAdapter(eObject); + CompositeNode node = nodeAdapter.getParserNode(); + + String text = serializerUtil.serialize(eObject, false); + TextEdit edit = new ReplaceEdit(node.getOffset(), node.getLength(), text); + edits.add(edit); + } + return edits; + } + +}
\ No newline at end of file diff --git a/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/edit/IDocumentEditor.java b/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/edit/IDocumentEditor.java new file mode 100644 index 0000000..cf8b240 --- a/dev/null +++ b/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/edit/IDocumentEditor.java @@ -0,0 +1,24 @@ +/******************************************************************************* + * Copyright (c) 2009 itemis AG (http://www.itemis.eu) and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.eclipse.xtext.ui.core.editor.model.edit; + +import org.eclipse.xtext.concurrent.IUnitOfWork; +import org.eclipse.xtext.resource.XtextResource; +import org.eclipse.xtext.ui.core.editor.model.IXtextDocument; + +import com.google.inject.ImplementedBy; + +/** + * @author Knut Wannheden - Initial contribution and API + */ +@ImplementedBy(DefaultDocumentEditor.class) +public interface IDocumentEditor { + + <T> T process(final IUnitOfWork<T, XtextResource> work, final IXtextDocument document); + +} diff --git a/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/edit/ITextEditComposer.java b/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/edit/ITextEditComposer.java new file mode 100644 index 0000000..aba3130 --- a/dev/null +++ b/plugins/org.eclipse.xtext.ui.core/src/org/eclipse/xtext/ui/core/editor/model/edit/ITextEditComposer.java @@ -0,0 +1,27 @@ +/******************************************************************************* + * Copyright (c) 2009 itemis AG (http://www.itemis.eu) and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.eclipse.xtext.ui.core.editor.model.edit; + +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.text.edits.TextEdit; + +import com.google.inject.ImplementedBy; + +/** + * @author Knut Wannheden - Initial contribution and API + */ +@ImplementedBy(DefaultTextEditComposer.class) +public interface ITextEditComposer { + + void beginRecording(Resource resource); + + TextEdit endRecording(); + + TextEdit getTextEdit(); + +} diff --git a/tests/org.eclipse.xtext.ui.core.tests/META-INF/MANIFEST.MF b/tests/org.eclipse.xtext.ui.core.tests/META-INF/MANIFEST.MF index 41705d1..f048db4 100644 --- a/tests/org.eclipse.xtext.ui.core.tests/META-INF/MANIFEST.MF +++ b/tests/org.eclipse.xtext.ui.core.tests/META-INF/MANIFEST.MF @@ -12,7 +12,8 @@ Require-Bundle: org.eclipse.xtext.ui.core;bundle-version="0.8.0", org.eclipse.xtext.generator;bundle-version="0.8.0", org.eclipse.jdt.core;bundle-version="3.4.0", org.eclipse.jdt.launching;bundle-version="3.4.0", - org.eclipse.xtext + org.eclipse.xtext, + org.eclipse.xtext.junit;bundle-version="0.8.0" Bundle-Activator: org.eclipse.xtext.ui.core.internal.XtextUICoreTestsPlugin Bundle-ActivationPolicy: lazy Bundle-Vendor: Eclipse.org diff --git a/tests/org.eclipse.xtext.ui.core.tests/src/org/eclipse/xtext/ui/core/editor/model/edit/DefaultDocumentEditorTest.java b/tests/org.eclipse.xtext.ui.core.tests/src/org/eclipse/xtext/ui/core/editor/model/edit/DefaultDocumentEditorTest.java new file mode 100644 index 0000000..d252e45 --- a/dev/null +++ b/tests/org.eclipse.xtext.ui.core.tests/src/org/eclipse/xtext/ui/core/editor/model/edit/DefaultDocumentEditorTest.java @@ -0,0 +1,65 @@ +/******************************************************************************* + * Copyright (c) 2009 itemis AG (http://www.itemis.eu) and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.eclipse.xtext.ui.core.editor.model.edit; + +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.xtext.Grammar; +import org.eclipse.xtext.XtextStandaloneSetup; +import org.eclipse.xtext.concurrent.IUnitOfWork; +import org.eclipse.xtext.junit.AbstractXtextTests; +import org.eclipse.xtext.resource.XtextResource; +import org.eclipse.xtext.ui.core.editor.model.IXtextDocument; +import org.eclipse.xtext.ui.core.editor.model.XtextDocument; +import org.eclipse.xtext.util.StringInputStream; + +/** + * @author Knut Wannheden - Initial contribution and API + */ +public class DefaultDocumentEditorTest extends AbstractXtextTests { + + private IDocumentEditor editor; + + @Override + protected void setUp() throws Exception { + super.setUp(); + with(XtextStandaloneSetup.class); + editor = get(IDocumentEditor.class); + } + + public void testProcess() throws Exception { + String grammar = "grammar foo.Foo " + "generate foo \"foo://foo/42\" " + "Foo: \"foo\" | \"bar\" | \"baz\"; " + + "Bar: foo=Foo; "; + final Resource res = getResource(new StringInputStream(grammar)); + final Object expected = res.getContents().get(0); + final IXtextDocument document = new XtextDocument() { + @Override + public <T> T modify(IUnitOfWork<T, XtextResource> work) { + try { + return work.exec((XtextResource) res); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + }; + document.set(grammar); + + Object result = editor.process(new IUnitOfWork<Object, XtextResource>() { + public Object exec(XtextResource state) throws Exception { + assertEquals(res, state); + Grammar grammar = (Grammar) state.getContents().get(0); + grammar.setName("foo.Bar"); + return grammar; + } + }, document); + + assertEquals(expected, result); + assertEquals(grammar.replaceFirst("foo\\.Foo", "foo.Bar"), document.get()); + } + +} diff --git a/tests/org.eclipse.xtext.ui.core.tests/src/org/eclipse/xtext/ui/core/editor/model/edit/DefaultTextEditComposerTest.java b/tests/org.eclipse.xtext.ui.core.tests/src/org/eclipse/xtext/ui/core/editor/model/edit/DefaultTextEditComposerTest.java new file mode 100644 index 0000000..7bb4659 --- a/dev/null +++ b/tests/org.eclipse.xtext.ui.core.tests/src/org/eclipse/xtext/ui/core/editor/model/edit/DefaultTextEditComposerTest.java @@ -0,0 +1,157 @@ +/******************************************************************************* + * Copyright (c) 2009 itemis AG (http://www.itemis.eu) and others. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + *******************************************************************************/ +package org.eclipse.xtext.ui.core.editor.model.edit; + +import java.io.InputStream; + +import org.eclipse.emf.ecore.EObject; +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.text.edits.ReplaceEdit; +import org.eclipse.text.edits.TextEdit; +import org.eclipse.xtext.AbstractRule; +import org.eclipse.xtext.Alternatives; +import org.eclipse.xtext.Grammar; +import org.eclipse.xtext.Keyword; +import org.eclipse.xtext.ParserRule; +import org.eclipse.xtext.XtextFactory; +import org.eclipse.xtext.XtextStandaloneSetup; +import org.eclipse.xtext.junit.AbstractXtextTests; +import org.eclipse.xtext.parsetree.AbstractNode; +import org.eclipse.xtext.parsetree.NodeUtil; +import org.eclipse.xtext.parsetree.impl.ParsetreeUtil; +import org.eclipse.xtext.util.StringInputStream; + +/** + * @author Knut Wannheden - Initial contribution and API + */ +public class DefaultTextEditComposerTest extends AbstractXtextTests { + + private ITextEditComposer composer; + + @Override + protected void setUp() throws Exception { + super.setUp(); + with(new XtextStandaloneSetup()); + composer = get(ITextEditComposer.class); + } + + private InputStream newTestGrammar() { + return new StringInputStream("grammar foo.Foo " + "generate foo 'foo://foo/42' " + + "Foo: 'foo' | 'bar' | 'baz'; " + "Bar: foo=Foo; "); + } + + public void testProtocol() throws Exception { + Resource res = getResource(newTestGrammar()); + assertNull(composer.endRecording()); + composer.beginRecording(res); + assertNull(composer.endRecording()); + assertNull(composer.endRecording()); + composer.beginRecording(res); + Grammar grammar = (Grammar) res.getContents().get(0); + ParserRule rule = (ParserRule) grammar.getRules().get(0); + rule.setName("Bar"); + assertNotNull(composer.endRecording()); + assertNull(composer.endRecording()); + } + + public void testRemoveRootObject() throws Exception { + Resource res = getResource(newTestGrammar()); + + composer.beginRecording(res); + res.getContents().clear(); + TextEdit edit = composer.endRecording(); + + assertEquals("", ((ReplaceEdit) edit).getText()); + } + + public void testReplaceRootObject() throws Exception { + Resource res = getResource(newTestGrammar()); + + composer.beginRecording(res); + Grammar grammar = (Grammar) getResource( + new StringInputStream("grammar bar.Bar " + "generate bar 'bar://bar/43' " + "Bar: 'bar'; ")) + .getContents().get(0); + res.getContents().set(0, grammar); + TextEdit edit = composer.endRecording(); + + assertMatches(grammar, edit); + } + + public void testObjectAddition() throws Exception { + Resource res = getResource(newTestGrammar()); + + composer.beginRecording(res); + Grammar grammar = (Grammar) res.getContents().get(0); + ParserRule rule = (ParserRule) grammar.getRules().get(0); + Alternatives alternatives = (Alternatives) rule.getAlternatives(); + Keyword keyword = XtextFactory.eINSTANCE.createKeyword(); + keyword.setValue("qux"); + alternatives.getGroups().add(keyword); + TextEdit edit = composer.endRecording(); + + assertMatches(alternatives, edit); + } + + public void testObjectRemoval() throws Exception { + Resource res = getResource(newTestGrammar()); + + composer.beginRecording(res); + Grammar grammar = (Grammar) res.getContents().get(0); + AbstractRule rule = grammar.getRules().get(0); + Alternatives alternatives = (Alternatives) rule.getAlternatives(); + alternatives.getGroups().remove(2); + TextEdit edit = composer.endRecording(); + + assertMatches(alternatives, edit); + } + + public void testObjectReplacement() throws Exception { + Resource res = getResource(newTestGrammar()); + + composer.beginRecording(res); + Grammar grammar = (Grammar) res.getContents().get(0); + ParserRule rule = (ParserRule) grammar.getRules().get(0); + Keyword keyword = XtextFactory.eINSTANCE.createKeyword(); + keyword.setValue("baz"); + rule.setAlternatives(keyword); + TextEdit edit = composer.endRecording(); + + assertMatches(rule, edit); + } + + public void testMultiEdit() throws Exception { + Resource res = getResource(newTestGrammar()); + + composer.beginRecording(res); + Grammar grammar = (Grammar) res.getContents().get(0); + ParserRule fooRule = (ParserRule) grammar.getRules().get(0); + ParserRule barRule = (ParserRule) grammar.getRules().get(1); + Alternatives fooAlternatives = (Alternatives) fooRule.getAlternatives(); + barRule.setAlternatives(fooAlternatives.getGroups().remove(0)); + TextEdit edit = composer.endRecording(); + + assertTrue(edit instanceof MultiTextEdit); + TextEdit[] children = ((MultiTextEdit) edit).getChildren(); + assertEquals(2, children.length); + assertMatches(fooAlternatives, children[0]); + assertMatches(barRule, children[1]); + } + + private void assertMatches(EObject obj, TextEdit edit) { + assertTrue(edit instanceof ReplaceEdit); + AbstractNode node = NodeUtil.getNodeAdapter(obj).getParserNode(); + assertEquals(ParsetreeUtil.getOffset(node), ((ReplaceEdit) edit).getOffset()); + assertEqualsIgnoringWhitespace(getSerializer().serialize(obj, false), ((ReplaceEdit) edit).getText()); + } + + private void assertEqualsIgnoringWhitespace(String expected, String actual) { + assertEquals(expected.replaceAll("\\s+", " "), actual.replaceAll("\\s+", " ")); + } + +} |

