diff --git a/README.md b/README.md index 0b34929..588cead 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,18 @@ unicodePwd, schema variations). Signed installers for macOS and Windows and a Linux tarball are available from [pointbluetech.com/arborj](https://www.pointbluetech.com/arborj/). +### On a Mac? Try Arbor + +**Mac users should consider [Arbor](https://www.pointbluetech.com/)**, the +commercial native macOS version of this tool. It's built in SwiftUI, needs no +Java runtime, runs natively on Apple Silicon and Intel, and syncs profiles +through iCloud. It's on the Mac App Store in two editions: + +- [Arbor LDAP Browser](https://apps.apple.com/us/app/arbor-ldap-browser/id6759270047?mt=12): + free, with Pro features as an in-app purchase. +- [ArborPro LDAP Browser](https://apps.apple.com/us/app/arborpro-ldap-browser/id6802792427?mt=12): + a one-time upfront purchase, suited to enterprise and volume purchasing. + --- ## Table of contents diff --git a/src/main/java/com/pointbluetech/arborj/service/AttributeSuggestSettings.java b/src/main/java/com/pointbluetech/arborj/service/AttributeSuggestSettings.java new file mode 100644 index 0000000..4ed9c44 --- /dev/null +++ b/src/main/java/com/pointbluetech/arborj/service/AttributeSuggestSettings.java @@ -0,0 +1,37 @@ +package com.pointbluetech.arborj.service; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; + +import java.util.prefs.Preferences; + +/** + * Whether ArborJ suggests schema attribute names while the user types. + * Default is on. Shared by the search filter, Filter Builder, Add Attribute, + * and Effective Rights. + */ +public final class AttributeSuggestSettings { + + private static final Preferences prefs = Preferences.userNodeForPackage(AttributeSuggestSettings.class); + private static final AttributeSuggestSettings INSTANCE = new AttributeSuggestSettings(); + private static final String PREF_SUGGEST_ATTRIBUTE_NAMES = "suggestAttributeNames"; + + private final BooleanProperty enabled = new SimpleBooleanProperty(); + + private AttributeSuggestSettings() { + enabled.set(prefs.getBoolean(PREF_SUGGEST_ATTRIBUTE_NAMES, true)); + enabled.addListener((obs, oldVal, newVal) -> prefs.putBoolean(PREF_SUGGEST_ATTRIBUTE_NAMES, newVal)); + } + + public static AttributeSuggestSettings getInstance() { + return INSTANCE; + } + + public boolean isEnabled() { + return enabled.get(); + } + + public BooleanProperty enabledProperty() { + return enabled; + } +} diff --git a/src/main/java/com/pointbluetech/arborj/util/LDAPFilterValidator.java b/src/main/java/com/pointbluetech/arborj/util/LDAPFilterValidator.java index 8f70830..1dfc495 100644 --- a/src/main/java/com/pointbluetech/arborj/util/LDAPFilterValidator.java +++ b/src/main/java/com/pointbluetech/arborj/util/LDAPFilterValidator.java @@ -41,10 +41,13 @@ public static boolean isValid(String filter) { /** * Extract the attribute name token at the given cursor position. - * Used for autocomplete — returns the partial attribute name being typed. + * Used for autocomplete — returns the partial attribute name being typed, + * or null when the caret is on the value side of an assertion (after {@code =}), + * including DN components inside that value. */ public static String attributeTokenAt(int cursor, String filter) { if (filter == null || cursor <= 0 || cursor > filter.length()) return null; + if (isOnAssertionValueSide(filter, cursor)) return null; char[] chars = filter.toCharArray(); // Walk left from cursor to find start of identifier int start = cursor - 1; @@ -57,6 +60,21 @@ public static String attributeTokenAt(int cursor, String filter) { return token.isEmpty() ? null : token; } + /** + * True when {@code cursor} is past the assertion operator of the filter + * item that contains it ({@code =}, {@code ~=}, {@code >=}, {@code <=}, + * {@code :=}). Scanning left, an {@code =} before {@code (} or {@code )} + * means the caret is in the value. + */ + private static boolean isOnAssertionValueSide(String filter, int cursor) { + for (int i = Math.min(cursor, filter.length()) - 1; i >= 0; i--) { + char c = filter.charAt(i); + if (c == '(' || c == ')') return false; + if (c == '=') return true; + } + return false; + } + private static boolean isAttrChar(char c) { return Character.isLetterOrDigit(c) || c == '-' || c == '.'; } diff --git a/src/main/java/com/pointbluetech/arborj/view/AttributeTableView.java b/src/main/java/com/pointbluetech/arborj/view/AttributeTableView.java index df01852..1aa9645 100644 --- a/src/main/java/com/pointbluetech/arborj/view/AttributeTableView.java +++ b/src/main/java/com/pointbluetech/arborj/view/AttributeTableView.java @@ -3,6 +3,7 @@ import com.pointbluetech.arborj.controller.MainController; import com.pointbluetech.arborj.controller.MainController.AttributeRow; import com.pointbluetech.arborj.model.LDAPAttributeSyntax; +import com.pointbluetech.arborj.service.AttributeSuggestSettings; import com.pointbluetech.arborj.service.FontSettings; import javafx.beans.property.SimpleStringProperty; import javafx.collections.transformation.FilteredList; @@ -927,6 +928,7 @@ private Optional promptForAttributeName(String dn) { ContextMenu suggestions = new ContextMenu(); field.textProperty().addListener((obs, oldVal, newVal) -> { suggestions.hide(); + if (!AttributeSuggestSettings.getInstance().isEnabled()) return; if (newVal == null || newVal.length() < 2) return; var attrMap = controller.getSchemaService().getAttributeMap(); if (attrMap == null) return; diff --git a/src/main/java/com/pointbluetech/arborj/view/EntryContextMenu.java b/src/main/java/com/pointbluetech/arborj/view/EntryContextMenu.java index b31e38c..569b9cd 100644 --- a/src/main/java/com/pointbluetech/arborj/view/EntryContextMenu.java +++ b/src/main/java/com/pointbluetech/arborj/view/EntryContextMenu.java @@ -1,6 +1,7 @@ package com.pointbluetech.arborj.view; import com.pointbluetech.arborj.controller.MainController; +import com.pointbluetech.arborj.service.AttributeSuggestSettings; import com.pointbluetech.arborj.model.DirectoryType; import com.pointbluetech.arborj.model.LDAPAttributeInfo; import com.pointbluetech.arborj.model.LDAPAttributeSyntax; @@ -340,6 +341,10 @@ private static void attachSchemaAutocomplete(TextField field, MainController con }; Runnable update = () -> { + if (!AttributeSuggestSettings.getInstance().isEnabled()) { + hide.run(); + return; + } String text = field.getText(); String lower = text == null ? "" : text.toLowerCase().trim(); if (lower.length() < 1) { hide.run(); return; } diff --git a/src/main/java/com/pointbluetech/arborj/view/FilterBuilderDialog.java b/src/main/java/com/pointbluetech/arborj/view/FilterBuilderDialog.java index 4d84b14..39714dd 100644 --- a/src/main/java/com/pointbluetech/arborj/view/FilterBuilderDialog.java +++ b/src/main/java/com/pointbluetech/arborj/view/FilterBuilderDialog.java @@ -1,6 +1,7 @@ package com.pointbluetech.arborj.view; import com.pointbluetech.arborj.controller.MainController; +import com.pointbluetech.arborj.service.AttributeSuggestSettings; import javafx.collections.FXCollections; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -830,6 +831,7 @@ private void setupAutocomplete(TextField field) { ContextMenu suggestions = new ContextMenu(); field.textProperty().addListener((_, _, newVal) -> { suggestions.hide(); + if (!AttributeSuggestSettings.getInstance().isEnabled()) return; if (newVal == null || newVal.length() < 2) return; try { var attrMap = controller.getSchemaService().getAttributeMap(); diff --git a/src/main/java/com/pointbluetech/arborj/view/MainView.java b/src/main/java/com/pointbluetech/arborj/view/MainView.java index 30e96ac..bbaddd5 100644 --- a/src/main/java/com/pointbluetech/arborj/view/MainView.java +++ b/src/main/java/com/pointbluetech/arborj/view/MainView.java @@ -34,6 +34,13 @@ public class MainView { private Label tableViewLink; private boolean smartInsertEnabled = java.util.prefs.Preferences .userNodeForPackage(MainView.class).getBoolean("smartInsert", true); + /** Search-filter attribute suggestions. A heavyweight Popup, so it can take focus. */ + private javafx.stage.Popup searchSuggestionPopup; + private ListView searchSuggestionList; + /** When Esc dismissed suggestions; a follow-up delivery of that key must not clear results. */ + private boolean suggestionEscapeDismissed; + private long suggestionEscapeDismissedAtNanos; + private static final long SUGGESTION_ESCAPE_GUARD_NANOS = 250_000_000L; private final javafx.beans.property.BooleanProperty logPaneVisible = new javafx.beans.property.SimpleBooleanProperty(false); @@ -121,8 +128,19 @@ public MainView(MainController controller) { refreshSelectedNodeOrTree(); e.consume(); } else if (e.getCode() == javafx.scene.input.KeyCode.ESCAPE) { - // Escape: Clear search results - if (!controller.getSearchResults().isEmpty()) { + // Dismiss attribute suggestions first. This filter runs in the + // capture phase, before the text field, and Esc must not also + // clear search results — including a second delivery after the + // popup window hid and focus returned here. + if (isSearchSuggestionPopupShowing()) { + dismissSearchSuggestions(); + e.consume(); + } else if (suggestionEscapeJustDismissed()) { + // Same key (or a repeat) after the popup hid. Consume it + // without re-arming the guard or moving focus, so a later + // Esc can still clear search results. + e.consume(); + } else if (!controller.getSearchResults().isEmpty()) { controller.clearSearch(); contentSplit.getItems().remove(searchResultsList); e.consume(); @@ -479,6 +497,89 @@ settingsItem, showLogsItem, checkUpdateItem, new SeparatorMenuItem(), return toolbar; } + private boolean isSearchSuggestionPopupShowing() { + return searchSuggestionPopup != null && searchSuggestionPopup.isShowing(); + } + + private boolean suggestionEscapeJustDismissed() { + return suggestionEscapeDismissed + && System.nanoTime() - suggestionEscapeDismissedAtNanos < SUGGESTION_ESCAPE_GUARD_NANOS; + } + + /** + * True when keyboard focus is in the suggestion list. Popup.isFocused() + * follows the owner window, so it stays true while the main window is focused. + */ + private boolean searchSuggestionPopupHasFocus() { + if (!isSearchSuggestionPopupShowing() || searchSuggestionList == null) return false; + var scene = searchSuggestionPopup.getScene(); + if (scene == null) return false; + for (var node = scene.getFocusOwner(); node != null; node = node.getParent()) { + if (node == searchSuggestionList) return true; + } + return false; + } + + /** + * Hide attribute suggestions and return keyboard focus to the search filter. + * Does not clear search results. + */ + private void dismissSearchSuggestions() { + if (searchSuggestionPopup != null) { + searchSuggestionPopup.hide(); + } + suggestionEscapeDismissed = true; + suggestionEscapeDismissedAtNanos = System.nanoTime(); + if (searchFilterField != null) { + searchFilterField.requestFocus(); + } + } + + private void handleSearchSuggestionKeys(javafx.scene.input.KeyEvent ev, + ListView list, + Runnable applySuggestion) { + if (!isSearchSuggestionPopupShowing()) return; + switch (ev.getCode()) { + case DOWN -> { + int last = list.getItems().size() - 1; + if (last < 0) return; + int idx = list.getSelectionModel().getSelectedIndex(); + list.getSelectionModel().select(Math.min(idx + 1, last)); + list.scrollTo(list.getSelectionModel().getSelectedIndex()); + ev.consume(); + } + case UP -> { + if (list.getItems().isEmpty()) return; + int idx = list.getSelectionModel().getSelectedIndex(); + list.getSelectionModel().select(Math.max(idx - 1, 0)); + list.scrollTo(list.getSelectionModel().getSelectedIndex()); + ev.consume(); + } + case ENTER, TAB -> { + if (list.getSelectionModel().getSelectedIndex() >= 0) { + applySuggestion.run(); + ev.consume(); + } + } + case ESCAPE -> { + dismissSearchSuggestions(); + ev.consume(); + } + default -> {} + } + } + + /** Keys still arrive when focus is on the popup scene rather than the list. */ + private void installSearchSuggestionPopupKeys(ListView list, Runnable applySuggestion) { + var scene = list.getScene(); + if (scene == null) return; + final String marker = "arborjSearchSuggestionKeys"; + if (Boolean.TRUE.equals(scene.getProperties().get(marker))) return; + scene.getProperties().put(marker, Boolean.TRUE); + scene.addEventFilter(javafx.scene.input.KeyEvent.KEY_PRESSED, ev -> + handleSearchSuggestionKeys(ev, list, applySuggestion)); + } + private VBox buildSearchBar() { VBox searchBar = new VBox(4); searchBar.getStyleClass().add("search-bar"); @@ -496,13 +597,20 @@ private VBox buildSearchBar() { // Autocomplete popup ListView autoComplete = new ListView<>(); + searchSuggestionList = autoComplete; autoComplete.setMaxHeight(200); autoComplete.setPrefHeight(150); autoComplete.setStyle("-fx-font-family: monospaced; -fx-font-size: 12;"); - // Autocomplete as a floating popup (not in layout) + // Autocomplete as a floating popup (not in layout). On Windows this + // heavyweight window can take keyboard focus, so keys are also handled + // on the popup content. javafx.stage.Popup autoPopup = new javafx.stage.Popup(); + searchSuggestionPopup = autoPopup; autoPopup.setAutoHide(true); + // Default is true, which hides the popup without arming the guard. + // Esc is handled only by the explicit dismiss path. + autoPopup.setHideOnEscape(false); autoPopup.getContent().add(autoComplete); Runnable showAutoComplete = () -> { @@ -516,7 +624,16 @@ private VBox buildSearchBar() { }; Runnable hideAutoComplete = () -> autoPopup.hide(); + var suggestSettings = com.pointbluetech.arborj.service.AttributeSuggestSettings.getInstance(); + suggestSettings.enabledProperty().addListener((obs, wasEnabled, enabled) -> { + if (!enabled) hideAutoComplete.run(); + }); + Runnable updateSuggestions = () -> { + if (!suggestSettings.isEnabled()) { + hideAutoComplete.run(); + return; + } int cursor = filterField.getCaretPosition(); String token = com.pointbluetech.arborj.util.LDAPFilterValidator .attributeTokenAt(cursor, filterField.getText()); @@ -556,7 +673,7 @@ private VBox buildSearchBar() { filterField.positionCaret(tokenStart + selected.length()); } hideAutoComplete.run(); - }; + }; filterField.textProperty().addListener((obs, o, n) -> updateSuggestions.run()); filterField.caretPositionProperty().addListener((obs, o, n) -> updateSuggestions.run()); @@ -564,46 +681,23 @@ private VBox buildSearchBar() { // Click to apply suggestion autoComplete.setOnMouseClicked(ev -> applySuggestion.run()); - // Arrow keys and Enter in filter field navigate the autocomplete - filterField.addEventFilter(javafx.scene.input.KeyEvent.KEY_PRESSED, ev -> { - if (!autoPopup.isShowing()) return; - switch (ev.getCode()) { - case DOWN -> { - int idx = autoComplete.getSelectionModel().getSelectedIndex(); - autoComplete.getSelectionModel().select(Math.min(idx + 1, autoComplete.getItems().size() - 1)); - autoComplete.scrollTo(autoComplete.getSelectionModel().getSelectedIndex()); - ev.consume(); - } - case UP -> { - int idx = autoComplete.getSelectionModel().getSelectedIndex(); - autoComplete.getSelectionModel().select(Math.max(idx - 1, 0)); - autoComplete.scrollTo(autoComplete.getSelectionModel().getSelectedIndex()); - ev.consume(); - } - case ENTER, TAB -> { - if (autoComplete.getSelectionModel().getSelectedIndex() >= 0) { - applySuggestion.run(); - ev.consume(); - } - } - case ESCAPE -> { - hideAutoComplete.run(); - ev.consume(); - } - default -> {} - } - }); + // Keys on the text field, and on the popup when it has taken focus. + filterField.addEventFilter(javafx.scene.input.KeyEvent.KEY_PRESSED, ev -> + handleSearchSuggestionKeys(ev, autoComplete, applySuggestion)); + autoComplete.addEventFilter(javafx.scene.input.KeyEvent.KEY_PRESSED, ev -> + handleSearchSuggestionKeys(ev, autoComplete, applySuggestion)); + autoPopup.setOnShown(ev -> installSearchSuggestionPopupKeys(autoComplete, applySuggestion)); - // Hide autocomplete when focus leaves filter + // Hide autocomplete when focus leaves the filter, unless the popup + // itself has focus (a click on the list, or Windows moving focus there). filterField.focusedProperty().addListener((obs, o, focused) -> { if (!focused) { - // Delay to allow click on autocomplete list javafx.animation.PauseTransition pause = new javafx.animation.PauseTransition( javafx.util.Duration.millis(200)); pause.setOnFinished(ev -> { - if (!filterField.isFocused()) { + if (!filterField.isFocused() && !searchSuggestionPopupHasFocus()) { hideAutoComplete.run(); - } + } }); pause.play(); } diff --git a/src/main/java/com/pointbluetech/arborj/view/SettingsDialog.java b/src/main/java/com/pointbluetech/arborj/view/SettingsDialog.java index b3d12fd..549f9fc 100644 --- a/src/main/java/com/pointbluetech/arborj/view/SettingsDialog.java +++ b/src/main/java/com/pointbluetech/arborj/view/SettingsDialog.java @@ -1,6 +1,7 @@ package com.pointbluetech.arborj.view; import com.pointbluetech.arborj.ArborJApp; +import com.pointbluetech.arborj.service.AttributeSuggestSettings; import com.pointbluetech.arborj.service.FontSettings; import javafx.geometry.Insets; import javafx.scene.Scene; @@ -50,7 +51,7 @@ public SettingsDialog(com.pointbluetech.arborj.service.CredentialStore credentia } VBox root = new VBox(tabs); - Scene scene = new Scene(root, 520, 520); + Scene scene = new Scene(root, 520, 640); stage.setScene(scene); } @@ -160,6 +161,25 @@ private Tab buildAppearanceTab() { preview.setMaxWidth(Double.MAX_VALUE); grid.add(preview, 1, row++); + grid.add(new Separator(), 0, row++, 2, 1); + + Label suggestHeader = new Label("Suggestions"); + suggestHeader.setStyle("-fx-font-weight: bold;"); + grid.add(suggestHeader, 0, row++, 2, 1); + + CheckBox suggestNames = new CheckBox("Suggest attribute names as you type"); + suggestNames.selectedProperty().bindBidirectional( + AttributeSuggestSettings.getInstance().enabledProperty()); + grid.add(suggestNames, 0, row++, 2, 1); + + Label suggestNote = new Label( + "Shows matching schema attribute names in the search filter, Filter Builder, " + + "Add Attribute, and Effective Rights."); + suggestNote.setStyle("-fx-text-fill: -color-fg-muted; -fx-font-size: 11;"); + suggestNote.setWrapText(true); + suggestNote.setMaxWidth(460); + grid.add(suggestNote, 0, row++, 2, 1); + // Column constraints ColumnConstraints labelCol = new ColumnConstraints(); labelCol.setPrefWidth(90); diff --git a/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java b/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java index be1f5ab..74055b1 100644 --- a/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java +++ b/src/main/java/com/pointbluetech/arborj/view/edir/EffectiveRightsView.java @@ -1,11 +1,13 @@ package com.pointbluetech.arborj.view.edir; import com.pointbluetech.arborj.controller.MainController; +import com.pointbluetech.arborj.service.AttributeSuggestSettings; import com.pointbluetech.arborj.model.EffectiveRights; import com.pointbluetech.arborj.model.EffectiveRights.AttributeRight; import com.pointbluetech.arborj.model.EffectiveRights.EntryRight; import com.pointbluetech.arborj.view.DNPickerDialog; import javafx.application.Platform; +import javafx.beans.value.ChangeListener; import javafx.collections.FXCollections; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -97,16 +99,41 @@ public EffectiveRightsView(MainController controller, javafx.stage.Window owner) // Populate from schema var attrMap = controller.getSchemaService().getAttributeMap(); - if (attrMap != null && !attrMap.isEmpty()) { - List sorted = attrMap.keySet().stream().sorted().toList(); - attributeCombo.setItems(FXCollections.observableArrayList(sorted)); + List allAttributes = (attrMap == null || attrMap.isEmpty()) + ? List.of() + : attrMap.keySet().stream().sorted().toList(); + if (!allAttributes.isEmpty()) { + attributeCombo.setItems(FXCollections.observableArrayList(allAttributes)); } + // Restore the full schema list only while the combo is still filtered. + // A keystroke against the complete list must leave an open dropdown alone. + Runnable restoreFullAttributeList = () -> { + if (allAttributes.isEmpty() || attributeCombo.getItems().equals(allAttributes)) return; + String editorText = attributeCombo.getEditor().getText(); + attributeCombo.getItems().setAll(allAttributes); + attributeCombo.getEditor().setText(editorText); + attributeCombo.hide(); + }; + ChangeListener suggestListener = (obs, wasEnabled, enabled) -> { + if (!enabled) { + restoreFullAttributeList.run(); + attributeCombo.hide(); + } + }; + var suggestSettings = AttributeSuggestSettings.getInstance(); + suggestSettings.enabledProperty().addListener(suggestListener); + setOnHidden(e -> suggestSettings.enabledProperty().removeListener(suggestListener)); + // Filter as user types attributeCombo.getEditor().textProperty().addListener((obs, oldVal, newVal) -> { + if (!AttributeSuggestSettings.getInstance().isEnabled()) { + restoreFullAttributeList.run(); + return; + } if (newVal == null || newVal.isEmpty()) { - if (attrMap != null) { - attributeCombo.getItems().setAll(attrMap.keySet().stream().sorted().toList()); + if (!allAttributes.isEmpty()) { + attributeCombo.getItems().setAll(allAttributes); } return; } diff --git a/src/test/java/com/pointbluetech/arborj/util/LDAPFilterValidatorTest.java b/src/test/java/com/pointbluetech/arborj/util/LDAPFilterValidatorTest.java index 6fa6ba6..dc51949 100644 --- a/src/test/java/com/pointbluetech/arborj/util/LDAPFilterValidatorTest.java +++ b/src/test/java/com/pointbluetech/arborj/util/LDAPFilterValidatorTest.java @@ -126,4 +126,59 @@ void attributeTokenAtHandlesBadCursor() { // Cursor past the end of string => null. assertNull(LDAPFilterValidator.attributeTokenAt(50, "(cn=alice)")); } + + @Test + @DisplayName("attributeTokenAt stops at the assertion operator") + void attributeTokenAtStopsAfterEquals() { + String filter = "(cn=bob)"; + assertEquals("cn", LDAPFilterValidator.attributeTokenAt(2, filter)); + assertEquals("cn", LDAPFilterValidator.attributeTokenAt(3, filter)); // caret before '=' + assertNull(LDAPFilterValidator.attributeTokenAt(4, filter)); // caret just after '=' + assertNull(LDAPFilterValidator.attributeTokenAt(5, filter)); // caret in the value + } + + @Test + @DisplayName("attributeTokenAt ignores DN components inside an assertion value") + void attributeTokenAtIgnoresDnValues() { + String filter = "(member=cn=admin,ou=users)"; + assertEquals("member", LDAPFilterValidator.attributeTokenAt(filter.indexOf("member") + 3, filter)); + assertNull(LDAPFilterValidator.attributeTokenAt(filter.indexOf("=cn=") + 2, filter)); + assertNull(LDAPFilterValidator.attributeTokenAt(filter.indexOf("admin") + 1, filter)); + assertNull(LDAPFilterValidator.attributeTokenAt(filter.indexOf("ou=") + 1, filter)); + } + + @Test + @DisplayName("attributeTokenAt still finds attributes in later filter items") + void attributeTokenAtFindsLaterAssertions() { + String filter = "(&(objectClass=person)(!(cn=bob)))"; + assertEquals("objectClass", + LDAPFilterValidator.attributeTokenAt(filter.indexOf("objectClass") + 4, filter)); + assertNull(LDAPFilterValidator.attributeTokenAt(filter.indexOf("person") + 1, filter)); + int cn = filter.indexOf("(cn=") + 1; + assertEquals("cn", LDAPFilterValidator.attributeTokenAt(cn + 2, filter)); + assertNull(LDAPFilterValidator.attributeTokenAt(cn + 3, filter)); + assertNull(LDAPFilterValidator.attributeTokenAt(filter.indexOf("bob") + 1, filter)); + } + + @Test + @DisplayName("attributeTokenAt treats compound operators as the value boundary") + void attributeTokenAtCompoundOperators() { + assertEquals("age", LDAPFilterValidator.attributeTokenAt(3, "(age>=18)")); + assertNull(LDAPFilterValidator.attributeTokenAt("(age>=18)".indexOf('=') + 1, "(age>=18)")); + assertNull(LDAPFilterValidator.attributeTokenAt("(cn~=smith)".indexOf("smith") + 1, "(cn~=smith)")); + String spaced = "( cn = bob )"; + assertEquals("cn", LDAPFilterValidator.attributeTokenAt(spaced.indexOf("cn") + 1, spaced)); + assertNull(LDAPFilterValidator.attributeTokenAt(spaced.indexOf("bob") + 1, spaced)); + } + + @Test + @DisplayName("attributeTokenAt still returns a partial name before any operator") + void attributeTokenAtPartialName() { + assertEquals("mail", LDAPFilterValidator.attributeTokenAt(5, "(mail")); + assertEquals("cn", LDAPFilterValidator.attributeTokenAt(2, "cn")); + assertNull(LDAPFilterValidator.attributeTokenAt(3, "cn=")); + String ext = "(cn:caseExactMatch:=Fred)"; + assertEquals("cn", LDAPFilterValidator.attributeTokenAt(ext.indexOf("cn") + 1, ext)); + assertNull(LDAPFilterValidator.attributeTokenAt(ext.indexOf("Fred") + 1, ext)); + } }