diff --git a/src/main/java/de/prob2/jupyter/commands/CommandUtils.java b/src/main/java/de/prob2/jupyter/commands/CommandUtils.java
index 8d1517e2241058b1fcd9248e9e9287070a03355b..aca5a81ffaf383fc4025233aead53ebf13ebd41b 100644
--- a/src/main/java/de/prob2/jupyter/commands/CommandUtils.java
+++ b/src/main/java/de/prob2/jupyter/commands/CommandUtils.java
@@ -38,6 +38,10 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 public final class CommandUtils {
+	public interface Completer {
+		public abstract @Nullable ReplacementOptions complete(final @NotNull String argString, final int offset);
+	}
+	
 	private static final @NotNull Logger LOGGER = LoggerFactory.getLogger(CommandUtils.class);
 	
 	public static final @NotNull Pattern ARG_SPLIT_PATTERN = Pattern.compile("\\h+");
@@ -220,6 +224,23 @@ public final class CommandUtils {
 		);
 	}
 	
+	public static @Nullable ReplacementOptions completeArgs(final @NotNull String argString, final int at, final @NotNull Completer @NotNull... completers) {
+		final Matcher argSplitMatcher = ARG_SPLIT_PATTERN.matcher(argString);
+		int argStart = 0;
+		int argEnd = argString.length();
+		int i = 0;
+		while (argSplitMatcher.find() && i < completers.length) {
+			if (argSplitMatcher.end() > at) {
+				argEnd = argSplitMatcher.start();
+				break;
+			}
+			argStart = argSplitMatcher.end();
+			i++;
+		}
+		final ReplacementOptions replacements = completers[i].complete(argString.substring(argStart, argEnd), at - argStart);
+		return replacements == null ? null : offsetReplacementOptions(replacements, argStart);
+	}
+	
 	public static @NotNull ReplacementOptions completeInBExpression(final @NotNull Trace trace, final @NotNull String code, final int at) {
 		final Matcher identifierMatcher = B_IDENTIFIER_PATTERN.matcher(code);
 		String identifier = "";
@@ -251,6 +272,10 @@ public final class CommandUtils {
 		return new ReplacementOptions(new ArrayList<>(completions), start, end);
 	}
 	
+	public static @NotNull Completer bExpressionCompleter(final @NotNull Trace trace) {
+		return (code, at) -> completeInBExpression(trace, code, at);
+	}
+	
 	public static @Nullable ReplacementOptions completeInPreferences(final @NotNull Trace trace, final @NotNull String code, final int at) {
 		final Matcher argSplitMatcher = ARG_SPLIT_PATTERN.matcher(code);
 		int prefNameStart = 0;
@@ -269,4 +294,8 @@ public final class CommandUtils {
 			return null;
 		}
 	}
+	
+	public static @NotNull Completer preferencesCompleter(final @NotNull Trace trace) {
+		return (code, at) -> completeInPreferences(trace, code, at);
+	}
 }
diff --git a/src/main/java/de/prob2/jupyter/commands/DotCommand.java b/src/main/java/de/prob2/jupyter/commands/DotCommand.java
index 53ee6926423c26dbe6d0f420b157e8edf52a93d5..6b2aecafcc9125c520d0e20ec806c8a4e57f1aa3 100644
--- a/src/main/java/de/prob2/jupyter/commands/DotCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/DotCommand.java
@@ -6,7 +6,6 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.Collections;
 import java.util.List;
-import java.util.regex.Matcher;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -26,6 +25,7 @@ import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
 import io.github.spencerpark.jupyter.kernel.display.DisplayData;
 
 import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
 
 public final class DotCommand implements Command {
 	private final @NotNull AnimationSelector animationSelector;
@@ -118,32 +118,23 @@ public final class DotCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull ReplacementOptions complete(final @NotNull String argString, final int at) {
-		final int cmdNameEnd;
-		final Matcher argSplitMatcher = CommandUtils.ARG_SPLIT_PATTERN.matcher(argString);
-		if (argSplitMatcher.find()) {
-			cmdNameEnd = argSplitMatcher.start();
-		} else {
-			cmdNameEnd = argString.length();
-		}
-		
-		if (cmdNameEnd < at) {
-			// Cursor is in the formula part of the arguments, provide B completions.
-			final ReplacementOptions replacements = CommandUtils.completeInBExpression(this.animationSelector.getCurrentTrace(), argString.substring(cmdNameEnd), at - cmdNameEnd);
-			return CommandUtils.offsetReplacementOptions(replacements, cmdNameEnd);
-		} else {
-			// Cursor is in the first part of the arguments, provide possible command names.
-			final Trace trace = this.animationSelector.getCurrentTrace();
-			final GetAllDotCommands cmd = new GetAllDotCommands(trace.getCurrentState());
-			trace.getStateSpace().execute(cmd);
-			final String prefix = argString.substring(0, at);
-			final List<String> commands = cmd.getCommands().stream()
-				.filter(DynamicCommandItem::isAvailable)
-				.map(DynamicCommandItem::getCommand)
-				.filter(s -> s.startsWith(prefix))
-				.sorted()
-				.collect(Collectors.toList());
-			return new ReplacementOptions(commands, 0, argString.length());
-		}
+	public @Nullable ReplacementOptions complete(final @NotNull String argString, final int at) {
+		return CommandUtils.completeArgs(
+			argString, at,
+			(commandName, at0) -> {
+				final Trace trace = this.animationSelector.getCurrentTrace();
+				final GetAllDotCommands cmd = new GetAllDotCommands(trace.getCurrentState());
+				trace.getStateSpace().execute(cmd);
+				final String prefix = commandName.substring(0, at0);
+				final List<String> commands = cmd.getCommands().stream()
+					.filter(DynamicCommandItem::isAvailable)
+					.map(DynamicCommandItem::getCommand)
+					.filter(s -> s.startsWith(prefix))
+					.sorted()
+					.collect(Collectors.toList());
+				return new ReplacementOptions(commands, 0, commandName.length());
+			},
+			CommandUtils.bExpressionCompleter(this.animationSelector.getCurrentTrace())
+		);
 	}
 }
diff --git a/src/main/java/de/prob2/jupyter/commands/ExecCommand.java b/src/main/java/de/prob2/jupyter/commands/ExecCommand.java
index 8d679fc0386dbb89e838ef3597e2b561409bbf39..ed7f52fe5bc96e23b4459a9e224f174f90ac195f 100644
--- a/src/main/java/de/prob2/jupyter/commands/ExecCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/ExecCommand.java
@@ -2,7 +2,6 @@ package de.prob2.jupyter.commands;
 
 import java.util.Collections;
 import java.util.List;
-import java.util.regex.Matcher;
 import java.util.stream.Collectors;
 
 import com.google.inject.Inject;
@@ -16,6 +15,7 @@ import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
 import io.github.spencerpark.jupyter.kernel.display.DisplayData;
 
 import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
 
 public final class ExecCommand implements Command {
 	private final @NotNull AnimationSelector animationSelector;
@@ -60,32 +60,23 @@ public final class ExecCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull ReplacementOptions complete(final @NotNull String argString, final int at) {
-		final int opNameEnd;
-		final Matcher argSplitMatcher = CommandUtils.ARG_SPLIT_PATTERN.matcher(argString);
-		if (argSplitMatcher.find()) {
-			opNameEnd = argSplitMatcher.start();
-		} else {
-			opNameEnd = argString.length();
-		}
-		
-		if (opNameEnd < at) {
-			// Cursor is in the predicate part of the arguments, provide B completions.
-			final ReplacementOptions replacements = CommandUtils.completeInBExpression(this.animationSelector.getCurrentTrace(), argString.substring(opNameEnd), at - opNameEnd);
-			return CommandUtils.offsetReplacementOptions(replacements, opNameEnd);
-		} else {
-			// Cursor is in the first part of the arguments, provide possible operation names.
-			final String prefix = argString.substring(0, at);
-			final List<String> opNames = this.animationSelector.getCurrentTrace()
-				.getNextTransitions()
-				.stream()
-				.map(Transition::getName)
-				.map(CommandUtils::prettyOperationName)
-				.distinct()
-				.filter(s -> s.startsWith(prefix))
-				.sorted()
-				.collect(Collectors.toList());
-			return new ReplacementOptions(opNames, 0, opNameEnd);
-		}
+	public @Nullable ReplacementOptions complete(final @NotNull String argString, final int at) {
+		return CommandUtils.completeArgs(
+			argString, at,
+			(operation, at0) -> {
+				final String prefix = operation.substring(0, at0);
+				final List<String> opNames = this.animationSelector.getCurrentTrace()
+					.getNextTransitions()
+					.stream()
+					.map(Transition::getName)
+					.map(CommandUtils::prettyOperationName)
+					.distinct()
+					.filter(s -> s.startsWith(prefix))
+					.sorted()
+					.collect(Collectors.toList());
+				return new ReplacementOptions(opNames, 0, operation.length());
+			},
+			CommandUtils.bExpressionCompleter(this.animationSelector.getCurrentTrace())
+		);
 	}
 }
diff --git a/src/main/java/de/prob2/jupyter/commands/LoadFileCommand.java b/src/main/java/de/prob2/jupyter/commands/LoadFileCommand.java
index d0f90818902dc8abfbf5bde23f4473b4bcdf564f..56143ae56ef98d3fe26c06db68f5a36b102fb9e8 100644
--- a/src/main/java/de/prob2/jupyter/commands/LoadFileCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/LoadFileCommand.java
@@ -8,7 +8,6 @@ import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
-import java.util.regex.Matcher;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -111,41 +110,32 @@ public final class LoadFileCommand implements Command {
 	
 	@Override
 	public @Nullable ReplacementOptions complete(final @NotNull String argString, final int at) {
-		final int fileNameEnd;
-		final Matcher argSplitMatcher = CommandUtils.ARG_SPLIT_PATTERN.matcher(argString);
-		if (argSplitMatcher.find()) {
-			fileNameEnd = argSplitMatcher.start();
-		} else {
-			fileNameEnd = argString.length();
-		}
-		
-		if (fileNameEnd < at) {
-			// Cursor is in the preferences, provide preference name completions.
-			final ReplacementOptions replacements = CommandUtils.completeInPreferences(this.animationSelector.getCurrentTrace(), argString.substring(fileNameEnd), at - fileNameEnd);
-			return replacements == null ? null : CommandUtils.offsetReplacementOptions(replacements, fileNameEnd);
-		} else {
-			// Cursor is in the file name, provide machine files from the current directory as completions.
-			final String prefix = argString.substring(0, at);
-			final List<String> fileNames;
-			try (final Stream<Path> list = Files.list(Paths.get(""))) {
-				fileNames = list
-					.map(Path::getFileName)
-					.map(Object::toString)
-					.filter(s -> s.startsWith(prefix))
-					.filter(s -> {
-						final int dotIndex = s.lastIndexOf('.');
-						if (dotIndex == -1) {
-							return false;
-						}
-						final String extension = s.substring(dotIndex+1);
-						return EXTENSION_TO_FACTORY_MAP.containsKey(extension);
-					})
-					.collect(Collectors.toList());
-			} catch (final IOException e) {
-				LOGGER.warn("Could not list contents of the current directory, cannot provide file name completions for :load", e);
-				return null;
-			}
-			return new ReplacementOptions(fileNames, 0, fileNameEnd);
-		}
+		return CommandUtils.completeArgs(
+			argString, at,
+			(filename, at0) -> {
+				final String prefix = filename.substring(0, at0);
+				final List<String> fileNames;
+				try (final Stream<Path> list = Files.list(Paths.get(""))) {
+					fileNames = list
+						.map(Path::getFileName)
+						.map(Object::toString)
+						.filter(s -> s.startsWith(prefix))
+						.filter(s -> {
+							final int dotIndex = s.lastIndexOf('.');
+							if (dotIndex == -1) {
+								return false;
+							}
+							final String extension = s.substring(dotIndex+1);
+							return EXTENSION_TO_FACTORY_MAP.containsKey(extension);
+						})
+						.collect(Collectors.toList());
+				} catch (final IOException e) {
+					LOGGER.warn("Could not list contents of the current directory, cannot provide file name completions for :load", e);
+					return null;
+				}
+				return new ReplacementOptions(fileNames, 0, filename.length());
+			},
+			CommandUtils.preferencesCompleter(this.animationSelector.getCurrentTrace())
+		);
 	}
 }
diff --git a/src/main/java/de/prob2/jupyter/commands/SolveCommand.java b/src/main/java/de/prob2/jupyter/commands/SolveCommand.java
index 6b451e540c6469080874e73b365751af93be4167..da3e5852044cb9e488933197d1a68aad548f5e68 100644
--- a/src/main/java/de/prob2/jupyter/commands/SolveCommand.java
+++ b/src/main/java/de/prob2/jupyter/commands/SolveCommand.java
@@ -3,7 +3,6 @@ package de.prob2.jupyter.commands;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
-import java.util.regex.Matcher;
 import java.util.stream.Collectors;
 
 import com.google.inject.Inject;
@@ -20,6 +19,7 @@ import io.github.spencerpark.jupyter.kernel.ReplacementOptions;
 import io.github.spencerpark.jupyter.kernel.display.DisplayData;
 
 import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
 
 public final class SolveCommand implements Command {
 	private static final @NotNull Map<@NotNull String, CbcSolveCommand.@NotNull Solvers> SOLVERS = Arrays.stream(CbcSolveCommand.Solvers.values())
@@ -76,27 +76,18 @@ public final class SolveCommand implements Command {
 	}
 	
 	@Override
-	public @NotNull ReplacementOptions complete(final @NotNull String argString, final int at) {
-		final int solverNameEnd;
-		final Matcher argSplitMatcher = CommandUtils.ARG_SPLIT_PATTERN.matcher(argString);
-		if (argSplitMatcher.find()) {
-			solverNameEnd = argSplitMatcher.start();
-		} else {
-			solverNameEnd = argString.length();
-		}
-		
-		if (solverNameEnd < at) {
-			// Cursor is in the predicate part of the arguments, provide B completions.
-			final ReplacementOptions replacements = CommandUtils.completeInBExpression(this.animationSelector.getCurrentTrace(), argString.substring(solverNameEnd), at - solverNameEnd);
-			return CommandUtils.offsetReplacementOptions(replacements, solverNameEnd);
-		} else {
-			// Cursor is in the solver name.
-			final String prefix = argString.substring(0, at);
-			final List<String> solverNames = SOLVERS.keySet().stream()
-				.filter(s -> s.startsWith(prefix))
-				.sorted()
-				.collect(Collectors.toList());
-			return new ReplacementOptions(solverNames, 0, solverNameEnd);
-		}
+	public @Nullable ReplacementOptions complete(final @NotNull String argString, final int at) {
+		return CommandUtils.completeArgs(
+			argString, at,
+			(solverName, at0) -> {
+				final String prefix = solverName.substring(0, at0);
+				final List<String> solverNames = SOLVERS.keySet().stream()
+					.filter(s -> s.startsWith(prefix))
+					.sorted()
+					.collect(Collectors.toList());
+				return new ReplacementOptions(solverNames, 0, solverName.length());
+			},
+			CommandUtils.bExpressionCompleter(this.animationSelector.getCurrentTrace())
+		);
 	}
 }