diff --git a/CHANGELOG.md b/CHANGELOG.md index 52210726..90fee9c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Added +- Experimental support for resolving variables. + The feature is disabled by default since the functionality is rather limited for now. + Feel free to comment your feedback at [issue #87](https://github.com/NixOS/nix-idea/issues/87). + ### Changed ### Deprecated diff --git a/src/main/java/org/nixos/idea/lang/builtins/NixBuiltin.java b/src/main/java/org/nixos/idea/lang/builtins/NixBuiltin.java index 521c37ba..7549896c 100644 --- a/src/main/java/org/nixos/idea/lang/builtins/NixBuiltin.java +++ b/src/main/java/org/nixos/idea/lang/builtins/NixBuiltin.java @@ -200,7 +200,11 @@ private NixBuiltin(@NotNull String name, this.global = GLOBAL_SCOPE.contains(name); } - public HighlightingType highlightingType() { + public @NotNull String name() { + return name; + } + + public @NotNull HighlightingType highlightingType() { return highlightingType; } diff --git a/src/main/java/org/nixos/idea/lang/references/NixNavigationTarget.java b/src/main/java/org/nixos/idea/lang/references/NixNavigationTarget.java new file mode 100644 index 00000000..128b5502 --- /dev/null +++ b/src/main/java/org/nixos/idea/lang/references/NixNavigationTarget.java @@ -0,0 +1,59 @@ +package org.nixos.idea.lang.references; + +import com.intellij.model.Pointer; +import com.intellij.openapi.util.TextRange; +import com.intellij.platform.backend.navigation.NavigationRequest; +import com.intellij.platform.backend.navigation.NavigationTarget; +import com.intellij.platform.backend.presentation.TargetPresentation; +import com.intellij.psi.SmartPointerManager; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.TestOnly; +import org.nixos.idea.psi.NixPsiElement; + +@SuppressWarnings("UnstableApiUsage") +public final class NixNavigationTarget implements NavigationTarget { + + private final @NotNull NixPsiElement myIdentifier; + private final @NotNull TargetPresentation myTargetPresentation; + private @Nullable Pointer myPointer; + + public NixNavigationTarget(@NotNull NixPsiElement identifier, @NotNull TargetPresentation targetPresentation) { + myIdentifier = identifier; + myTargetPresentation = targetPresentation; + } + + private NixNavigationTarget(@NotNull Pointer pointer, + @NotNull NixPsiElement identifier, + @NotNull TargetPresentation targetPresentation) { + myIdentifier = identifier; + myTargetPresentation = targetPresentation; + myPointer = pointer; + } + + @TestOnly + TextRange getRangeInFile() { + return myIdentifier.getTextRange(); + } + + @Override + public @NotNull Pointer createPointer() { + if (myPointer == null) { + TargetPresentation targetPresentation = myTargetPresentation; + myPointer = Pointer.uroborosPointer( + SmartPointerManager.createPointer(myIdentifier), + (identifier, pointer) -> new NixNavigationTarget(pointer, identifier, targetPresentation)); + } + return myPointer; + } + + @Override + public @NotNull TargetPresentation computePresentation() { + return myTargetPresentation; + } + + @Override + public @Nullable NavigationRequest navigationRequest() { + return NavigationRequest.sourceNavigationRequest(myIdentifier.getContainingFile(), myIdentifier.getTextRange()); + } +} diff --git a/src/main/java/org/nixos/idea/lang/references/NixScopeReference.java b/src/main/java/org/nixos/idea/lang/references/NixScopeReference.java new file mode 100644 index 00000000..2c942504 --- /dev/null +++ b/src/main/java/org/nixos/idea/lang/references/NixScopeReference.java @@ -0,0 +1,20 @@ +package org.nixos.idea.lang.references; + +import org.jetbrains.annotations.NotNull; +import org.nixos.idea.lang.references.symbol.NixSymbol; +import org.nixos.idea.psi.NixPsiElement; + +import java.util.Collection; + +@SuppressWarnings("UnstableApiUsage") +public final class NixScopeReference extends NixSymbolReference { + + public NixScopeReference(@NotNull NixPsiElement element, @NotNull NixPsiElement identifier, @NotNull String variableName) { + super(element, identifier, variableName); + } + + @Override + public @NotNull Collection resolveReference() { + return myElement.getScope().resolveVariable(myName); + } +} diff --git a/src/main/java/org/nixos/idea/lang/references/NixSymbolDeclaration.java b/src/main/java/org/nixos/idea/lang/references/NixSymbolDeclaration.java new file mode 100644 index 00000000..1c6c6df2 --- /dev/null +++ b/src/main/java/org/nixos/idea/lang/references/NixSymbolDeclaration.java @@ -0,0 +1,57 @@ +package org.nixos.idea.lang.references; + +import com.intellij.model.psi.PsiSymbolDeclaration; +import com.intellij.openapi.util.TextRange; +import com.intellij.platform.backend.navigation.NavigationTarget; +import com.intellij.platform.backend.presentation.TargetPresentation; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.nixos.idea.lang.references.symbol.NixUserSymbol; +import org.nixos.idea.psi.NixPsiElement; +import org.nixos.idea.util.TextRangeFactory; + +@SuppressWarnings("UnstableApiUsage") +public final class NixSymbolDeclaration implements PsiSymbolDeclaration { + + private final @NotNull NixPsiElement myDeclarationElement; + private final @NotNull NixPsiElement myIdentifier; + private final @NotNull NixUserSymbol mySymbol; + private final @NotNull String myDeclarationElementName; + private final @Nullable String myDeclarationElementType; + + public NixSymbolDeclaration(@NotNull NixPsiElement declarationElement, @NotNull NixPsiElement identifier, + @NotNull NixUserSymbol symbol, + @NotNull String declarationElementName, @Nullable String declarationElementType) { + myDeclarationElement = declarationElement; + myIdentifier = identifier; + mySymbol = symbol; + myDeclarationElementName = declarationElementName; + myDeclarationElementType = declarationElementType; + } + + public @NotNull NixPsiElement getIdentifier() { + return myIdentifier; + } + + public @NotNull NavigationTarget navigationTarget() { + return new NixNavigationTarget(myIdentifier, TargetPresentation.builder(mySymbol.presentation()) + .presentableText(myDeclarationElementName) + .containerText(myDeclarationElementType) + .presentation()); + } + + @Override + public @NotNull NixPsiElement getDeclaringElement() { + return myDeclarationElement; + } + + @Override + public @NotNull TextRange getRangeInDeclaringElement() { + return TextRangeFactory.relative(myIdentifier, myDeclarationElement); + } + + @Override + public @NotNull NixUserSymbol getSymbol() { + return mySymbol; + } +} diff --git a/src/main/java/org/nixos/idea/lang/references/NixSymbolReference.java b/src/main/java/org/nixos/idea/lang/references/NixSymbolReference.java new file mode 100644 index 00000000..d0985642 --- /dev/null +++ b/src/main/java/org/nixos/idea/lang/references/NixSymbolReference.java @@ -0,0 +1,46 @@ +package org.nixos.idea.lang.references; + +import com.intellij.model.Symbol; +import com.intellij.model.psi.PsiSymbolReference; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiElement; +import org.jetbrains.annotations.NotNull; +import org.nixos.idea.lang.references.symbol.NixSymbol; +import org.nixos.idea.psi.NixPsiElement; +import org.nixos.idea.util.TextRangeFactory; + +@SuppressWarnings("UnstableApiUsage") +public abstract class NixSymbolReference implements PsiSymbolReference { + + protected final @NotNull NixPsiElement myElement; + protected final @NotNull NixPsiElement myIdentifier; + protected final @NotNull String myName; + + protected NixSymbolReference(@NotNull NixPsiElement element, @NotNull NixPsiElement identifier, @NotNull String name) { + myElement = element; + myIdentifier = identifier; + myName = name; + } + + public @NotNull NixPsiElement getIdentifier() { + return myIdentifier; + } + + @Override + public @NotNull PsiElement getElement() { + return myElement; + } + + @Override + public @NotNull TextRange getRangeInElement() { + return TextRangeFactory.relative(myIdentifier, myElement); + } + + @Override + public boolean resolvesTo(@NotNull Symbol target) { + // Check name as a shortcut to avoid resolving the reference when it cannot match anyway. + return target instanceof NixSymbol t && + myName.equals(t.getName()) && + PsiSymbolReference.super.resolvesTo(target); + } +} diff --git a/src/main/java/org/nixos/idea/lang/references/NixUsage.java b/src/main/java/org/nixos/idea/lang/references/NixUsage.java new file mode 100644 index 00000000..dd8db499 --- /dev/null +++ b/src/main/java/org/nixos/idea/lang/references/NixUsage.java @@ -0,0 +1,69 @@ +package org.nixos.idea.lang.references; + +import com.intellij.find.usages.api.PsiUsage; +import com.intellij.find.usages.api.ReadWriteUsage; +import com.intellij.find.usages.api.UsageAccess; +import com.intellij.model.Pointer; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiFile; +import com.intellij.psi.SmartPointerManager; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.nixos.idea.psi.NixPsiElement; +import org.nixos.idea.settings.NixSymbolSettings; + +@SuppressWarnings("UnstableApiUsage") +final class NixUsage implements PsiUsage, ReadWriteUsage { + + private final @NotNull NixPsiElement myIdentifier; + private final boolean myIsDeclaration; + private @Nullable Pointer myPointer; + + NixUsage(@NotNull NixSymbolDeclaration declaration) { + myIdentifier = declaration.getIdentifier(); + myIsDeclaration = true; + } + + NixUsage(@NotNull NixSymbolReference reference) { + myIdentifier = reference.getIdentifier(); + myIsDeclaration = false; + } + + private NixUsage(@NotNull Pointer pointer, @NotNull NixPsiElement identifier, boolean isDeclaration) { + myIdentifier = identifier; + myIsDeclaration = isDeclaration; + myPointer = pointer; + } + + @Override + public @NotNull Pointer createPointer() { + if (myPointer == null) { + boolean isDeclaration = myIsDeclaration; + myPointer = Pointer.uroborosPointer( + SmartPointerManager.createPointer(myIdentifier), + (identifier, pointer) -> new NixUsage(pointer, identifier, isDeclaration)); + } + return myPointer; + } + + @Override + public @NotNull PsiFile getFile() { + return myIdentifier.getContainingFile(); + } + + @Override + public @NotNull TextRange getRange() { + return myIdentifier.getTextRange(); + } + + @Override + public boolean getDeclaration() { + // IDEA removes all instances which return true from the result of the usage search + return !NixSymbolSettings.getInstance().getShowDeclarationsAsUsages() && myIsDeclaration; + } + + @Override + public @Nullable UsageAccess computeAccess() { + return myIsDeclaration ? UsageAccess.Write : UsageAccess.Read; + } +} diff --git a/src/main/java/org/nixos/idea/lang/references/NixUsageSearcher.java b/src/main/java/org/nixos/idea/lang/references/NixUsageSearcher.java new file mode 100644 index 00000000..f4618ff0 --- /dev/null +++ b/src/main/java/org/nixos/idea/lang/references/NixUsageSearcher.java @@ -0,0 +1,69 @@ +package org.nixos.idea.lang.references; + +import com.intellij.find.usages.api.Usage; +import com.intellij.find.usages.api.UsageSearchParameters; +import com.intellij.find.usages.api.UsageSearcher; +import com.intellij.model.search.LeafOccurrence; +import com.intellij.model.search.LeafOccurrenceMapper; +import com.intellij.model.search.SearchContext; +import com.intellij.model.search.SearchService; +import com.intellij.psi.PsiElement; +import com.intellij.util.Query; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.nixos.idea.lang.NixLanguage; +import org.nixos.idea.lang.references.symbol.NixSymbol; +import org.nixos.idea.lang.references.symbol.NixUserSymbol; +import org.nixos.idea.psi.NixPsiElement; +import org.nixos.idea.settings.NixSymbolSettings; + +import java.util.Collection; +import java.util.List; + +@SuppressWarnings("UnstableApiUsage") +public final class NixUsageSearcher implements UsageSearcher, LeafOccurrenceMapper.Parameterized { + + @Override + public @NotNull Collection collectImmediateResults(@NotNull UsageSearchParameters parameters) { + if (!NixSymbolSettings.getInstance().getEnabled()) { + return List.of(); + } else if (parameters.getTarget() instanceof NixUserSymbol symbol) { + return symbol.getDeclarations().stream().map(NixUsage::new).toList(); + } else { + return List.of(); + } + } + + @Override + public @Nullable Query collectSearchRequest(@NotNull UsageSearchParameters parameters) { + if (!NixSymbolSettings.getInstance().getEnabled()) { + return null; + } else if (parameters.getTarget() instanceof NixSymbol symbol) { + String name = symbol.getName(); + return SearchService.getInstance() + .searchWord(parameters.getProject(), name) + .inContexts(SearchContext.IN_CODE_HOSTS, SearchContext.IN_CODE) + .inScope(parameters.getSearchScope()) + .inFilesWithLanguage(NixLanguage.INSTANCE) + .buildQuery(LeafOccurrenceMapper.withPointer(symbol.createPointer(), this)); + } else { + return null; + } + } + + @Override + public @NotNull Collection mapOccurrence(@NotNull NixSymbol symbol, @NotNull LeafOccurrence occurrence) { + for (PsiElement element = occurrence.getStart(); element != null && element != occurrence.getScope(); element = element.getParent()) { + if (element instanceof NixPsiElement nixElement) { + List usages = nixElement.getOwnReferences().stream() + .filter(reference -> reference.resolvesTo(symbol)) + .map(NixUsage::new) + .toList(); + if (!usages.isEmpty()) { + return usages; + } + } + } + return List.of(); + } +} diff --git a/src/main/java/org/nixos/idea/lang/references/Scope.java b/src/main/java/org/nixos/idea/lang/references/Scope.java new file mode 100644 index 00000000..bca5a868 --- /dev/null +++ b/src/main/java/org/nixos/idea/lang/references/Scope.java @@ -0,0 +1,140 @@ +package org.nixos.idea.lang.references; + +import com.intellij.util.containers.ContainerUtil; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.nixos.idea.lang.builtins.NixBuiltin; +import org.nixos.idea.lang.references.symbol.NixSymbol; +import org.nixos.idea.psi.NixDeclarationHost; +import org.nixos.idea.psi.NixExprWith; +import org.nixos.idea.psi.NixPsiElement; + +import java.util.Collection; + +/** + * Scope used to lookup accessible variables. + * This class is immutable, but it stores references to PSI elements. + * If the underlying PSI elements change after the scope instance was created, + * the scope instance may or may not reflect the changes. + */ +public abstract sealed class Scope { + + //region Factory methods + + @Contract(pure = true) + public static @NotNull Scope root() { + return Root.INSTANCE; + } + + /** + * Use {@link NixPsiElement#getScope()} instead of calling this method directly. + */ + @Contract(pure = true) + public static @NotNull Scope subScope(@NotNull Scope parent, @NotNull NixPsiElement element) { + if (element instanceof NixExprWith withExpression) { + return new With(parent, withExpression); + } else if (element instanceof NixDeclarationHost declarationHost && declarationHost.isDeclaringVariables()) { + return new LetOrRecursiveSet(parent, declarationHost); + } else { + return parent; + } + } + + //endregion + //region Public API + + /** + * Resolves the given variable name and returns matching symbols. + * + * @param variableName The name of the variable. + * @return Symbols which may be references by the given variable. + */ + @Contract(pure = true) + public final @NotNull Collection resolveVariable(@NotNull String variableName) { + NixSymbol result = resolveVariable0(variableName); + return ContainerUtil.createMaybeSingletonList(result); + } + + //endregion + //region Abstract methods + + @Contract(pure = true) + abstract @Nullable NixSymbol resolveVariable0(@NotNull String variableName); + + //endregion + //region Subclasses + + /** + * Represents the scope at the root of a file. + * The scope only contains built-ins provided by Nix itself. + * The same instance may be reused for multiple files. + */ + private static final class Root extends Scope { + + private static final Root INSTANCE = new Root(); + + private Root() {} // Only called for Root.INSTANCE + + @Override + public @Nullable NixSymbol resolveVariable0(@NotNull String variableName) { + // TODO: Ideally, we should filter the built-ins based on the used version of Nix. + NixBuiltin builtin = NixBuiltin.resolveGlobal(variableName); + return builtin == null ? null : NixSymbol.builtin(builtin); + } + } + + private static abstract sealed class Psi extends Scope { + private final @NotNull Scope myParent; + private final @NotNull T myElement; + + private Psi(@NotNull Scope parent, @NotNull T element) { + myParent = parent; + myElement = element; + } + + /** + * Returns the parent scope. + * + * @return Parent scope. + */ + public final @NotNull Scope getParent() { + return myParent; + } + + /** + * Returns the element at the root of the scope. + * + * @return Root of the scope. + */ + public final @NotNull T getElement() { + return myElement; + } + } + + private static final class LetOrRecursiveSet extends Psi { + private LetOrRecursiveSet(@NotNull Scope parent, @NotNull NixDeclarationHost element) { + super(parent, element); + } + + @Override + public @Nullable NixSymbol resolveVariable0(@NotNull String variableName) { + NixSymbol symbol = getElement().getSymbolForScope(variableName); + return symbol == null ? getParent().resolveVariable0(variableName) : symbol; + } + } + + private static final class With extends Psi { + private With(@NotNull Scope parent, @NotNull NixExprWith element) { + super(parent, element); + } + + @Override + public @Nullable NixSymbol resolveVariable0(@NotNull String variableName) { + // TODO with-expression reference support + return getParent().resolveVariable0(variableName); + } + } + + //endregion +} diff --git a/src/main/java/org/nixos/idea/lang/references/symbol/Commons.java b/src/main/java/org/nixos/idea/lang/references/symbol/Commons.java new file mode 100644 index 00000000..0dc9bd41 --- /dev/null +++ b/src/main/java/org/nixos/idea/lang/references/symbol/Commons.java @@ -0,0 +1,22 @@ +package org.nixos.idea.lang.references.symbol; + +import com.intellij.openapi.editor.colors.EditorColorsManager; +import com.intellij.openapi.editor.colors.EditorColorsScheme; +import com.intellij.openapi.editor.colors.TextAttributesKey; +import com.intellij.platform.backend.presentation.TargetPresentation; +import com.intellij.platform.backend.presentation.TargetPresentationBuilder; +import org.jetbrains.annotations.NotNull; + +import javax.swing.Icon; + +@SuppressWarnings("UnstableApiUsage") +final class Commons { + private Commons() {} // Cannot be instantiated + + static @NotNull TargetPresentationBuilder buildPresentation(@NotNull String name, @NotNull Icon icon, @NotNull TextAttributesKey textAttributesKey) { + EditorColorsScheme colorsScheme = EditorColorsManager.getInstance().getSchemeForCurrentUITheme(); + return TargetPresentation.builder(name) + .icon(icon) + .presentableTextAttributes(colorsScheme.getAttributes(textAttributesKey)); + } +} diff --git a/src/main/java/org/nixos/idea/lang/references/symbol/NixBuiltinSymbol.java b/src/main/java/org/nixos/idea/lang/references/symbol/NixBuiltinSymbol.java new file mode 100644 index 00000000..aa60cc4b --- /dev/null +++ b/src/main/java/org/nixos/idea/lang/references/symbol/NixBuiltinSymbol.java @@ -0,0 +1,55 @@ +package org.nixos.idea.lang.references.symbol; + +import com.intellij.icons.AllIcons; +import com.intellij.model.Pointer; +import com.intellij.platform.backend.presentation.TargetPresentation; +import org.jetbrains.annotations.NotNull; +import org.nixos.idea.lang.builtins.NixBuiltin; +import org.nixos.idea.lang.highlighter.NixTextAttributes; + +import java.util.Objects; + +@SuppressWarnings("UnstableApiUsage") +final class NixBuiltinSymbol extends NixSymbol + implements Pointer { + + private final @NotNull NixBuiltin myBuiltin; + + NixBuiltinSymbol(@NotNull NixBuiltin builtin) { + myBuiltin = builtin; + } + + @Override + public @NotNull String getName() { + return myBuiltin.name(); + } + + @Override + public @NotNull Pointer createPointer() { + return this; + } + + @Override + public @NotNull NixBuiltinSymbol dereference() { + return this; + } + + @Override + public @NotNull TargetPresentation presentation() { + return Commons.buildPresentation(myBuiltin.name(), AllIcons.Nodes.Padlock, NixTextAttributes.BUILTIN) + .presentation(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NixBuiltinSymbol builtin = (NixBuiltinSymbol) o; + return Objects.equals(myBuiltin, builtin.myBuiltin); + } + + @Override + public int hashCode() { + return Objects.hash(myBuiltin); + } +} diff --git a/src/main/java/org/nixos/idea/lang/references/symbol/NixSymbol.java b/src/main/java/org/nixos/idea/lang/references/symbol/NixSymbol.java new file mode 100644 index 00000000..1e333fb4 --- /dev/null +++ b/src/main/java/org/nixos/idea/lang/references/symbol/NixSymbol.java @@ -0,0 +1,32 @@ +package org.nixos.idea.lang.references.symbol; + +import com.intellij.find.usages.api.SearchTarget; +import com.intellij.find.usages.api.UsageHandler; +import com.intellij.model.Pointer; +import com.intellij.model.Symbol; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.nixos.idea.lang.builtins.NixBuiltin; + +@SuppressWarnings("UnstableApiUsage") +public abstract sealed class NixSymbol implements Symbol, SearchTarget + permits NixBuiltinSymbol, NixUserSymbol { + + NixSymbol() {} // Can only be implemented within this package + + @Contract(pure = true) + public static @NotNull NixSymbol builtin(@NotNull NixBuiltin builtin) { + return new NixBuiltinSymbol(builtin); + } + + @Contract(pure = true) + public abstract @NotNull String getName(); + + @Override + public abstract @NotNull Pointer createPointer(); + + @Override + public @NotNull UsageHandler getUsageHandler() { + return UsageHandler.createEmptyUsageHandler(getName()); + } +} diff --git a/src/main/java/org/nixos/idea/lang/references/symbol/NixUserSymbol.java b/src/main/java/org/nixos/idea/lang/references/symbol/NixUserSymbol.java new file mode 100644 index 00000000..4de980f3 --- /dev/null +++ b/src/main/java/org/nixos/idea/lang/references/symbol/NixUserSymbol.java @@ -0,0 +1,147 @@ +package org.nixos.idea.lang.references.symbol; + +import com.intellij.icons.AllIcons; +import com.intellij.model.Pointer; +import com.intellij.navigation.NavigatableSymbol; +import com.intellij.openapi.editor.colors.TextAttributesKey; +import com.intellij.openapi.project.Project; +import com.intellij.platform.backend.navigation.NavigationTarget; +import com.intellij.platform.backend.presentation.TargetPresentation; +import com.intellij.psi.PsiFile; +import com.intellij.psi.SmartPointerManager; +import com.intellij.psi.search.LocalSearchScope; +import com.intellij.psi.search.SearchScope; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.nixos.idea.lang.highlighter.NixTextAttributes; +import org.nixos.idea.lang.references.NixSymbolDeclaration; +import org.nixos.idea.psi.NixDeclarationHost; +import org.nixos.idea.settings.NixSymbolSettings; + +import javax.swing.Icon; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +@SuppressWarnings("UnstableApiUsage") +public final class NixUserSymbol extends NixSymbol + implements NavigatableSymbol { + + private final @NotNull NixDeclarationHost myHost; + private final @NotNull List myPath; + private final @NotNull Type myType; + @SuppressWarnings("FieldMayBeFinal") // Modified via MY_POINTER (VarHandle) + private @Nullable Pointer myPointer = null; + + public NixUserSymbol(@NotNull NixDeclarationHost host, @NotNull List path, @NotNull Type type) { + assert !path.isEmpty(); + myHost = host; + myPath = List.copyOf(path); + myType = type; + } + + @Override + public @NotNull String getName() { + return myPath.get(myPath.size() - 1); + } + + public @NotNull Collection getDeclarations() { + return myHost.getDeclarations(myPath); + } + + @Override + public @NotNull Pointer createPointer() { + if (myPointer == null) { + MY_POINTER.compareAndSet(this, null, Pointer.uroborosPointer( + SmartPointerManager.createPointer(myHost), + (host, pointer) -> dereference(host, myPath, pointer))); + Objects.requireNonNull(myPointer, "Pointer.uroborosPointer(...) must not return null"); + } + return myPointer; + } + + private static @Nullable NixUserSymbol dereference(@NotNull NixDeclarationHost host, @NotNull List path, + @NotNull Pointer pointer) { + NixUserSymbol symbol = host.getSymbol(path); + if (symbol != null) { + MY_POINTER.compareAndSet(symbol, null, pointer); + } + return symbol; + } + + @Override + public @NotNull TargetPresentation presentation() { + // TODO: TargetPresentationBuilder.locationText should specify the module (e.g. ). + // See also PsiElementNavigationTarget. + @Nullable PsiFile file = myHost.getContainingFile(); + return Commons.buildPresentation(getName(), myType.icon, myType.nameAttributes) + .locationText(file == null ? null : file.getName(), file == null ? null : file.getIcon(0)) + .presentation(); + } + + @Override + public @Nullable SearchScope getMaximalSearchScope() { + if (myType.localScope == null) { + return super.getMaximalSearchScope(); + } else { + assert myPath.size() == 1; + return new LocalSearchScope(myHost, myType.localScope); + } + } + + @Override + public @NotNull Collection getNavigationTargets(@NotNull Project project) { + assert myHost.getProject().equals(project); + Stream targets = myHost.getDeclarations(myPath).stream().map(NixSymbolDeclaration::navigationTarget); + if (NixSymbolSettings.getInstance().getJumpToFirstDeclaration()) { + return targets.findFirst().map(List::of).orElse(List.of()); + } else { + return targets.toList(); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NixUserSymbol symbol = (NixUserSymbol) o; + return Objects.equals(myHost, symbol.myHost) && Objects.equals(myPath, symbol.myPath); + } + + @Override + public int hashCode() { + return Objects.hash(myHost, myPath); + } + + public enum Type { + ATTRIBUTE(AllIcons.Nodes.Property, NixTextAttributes.IDENTIFIER, null), + PARAMETER(AllIcons.Nodes.Parameter, NixTextAttributes.PARAMETER, "Scope of Parameter"), + VARIABLE(AllIcons.Nodes.Variable, NixTextAttributes.LOCAL_VARIABLE, "Scope of Variable"), + + ; + + private final @NotNull Icon icon; + private final @NotNull TextAttributesKey nameAttributes; + private final @Nullable String localScope; + + Type(@NotNull Icon icon, @NotNull TextAttributesKey nameAttributes, @Nullable String localScope) { + this.icon = icon; + this.nameAttributes = nameAttributes; + this.localScope = localScope; + } + } + + // VarHandle mechanics + private static final VarHandle MY_POINTER; + static { + try { + MethodHandles.Lookup l = MethodHandles.lookup(); + MY_POINTER = l.findVarHandle(NixUserSymbol.class, "myPointer", Pointer.class); + } catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } +} diff --git a/src/main/java/org/nixos/idea/psi/NixDeclarationHost.java b/src/main/java/org/nixos/idea/psi/NixDeclarationHost.java new file mode 100644 index 00000000..a2cde546 --- /dev/null +++ b/src/main/java/org/nixos/idea/psi/NixDeclarationHost.java @@ -0,0 +1,54 @@ +package org.nixos.idea.psi; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.nixos.idea.lang.references.NixSymbolDeclaration; +import org.nixos.idea.lang.references.symbol.NixUserSymbol; + +import java.util.Collection; +import java.util.List; + +/** + * An element which may contain declarations. + * There are two types of declaration hosts: + *
    + *
  1. Elements which declare variables. + * The declared variables are accessible in the subtree of this element. + *
      + *
    • {@link NixExprLet} + *
    • {@link NixExprLambda} + *
    • {@link NixExprAttrs} if {@linkplain NixPsiUtil#isRecursive(NixExprAttrs) recursive} + *
    + *
  2. Elements which declare attributes. + * The attributes are accessible via the result of this expression. + *
      + *
    • {@link NixExprAttrs} if not a {@linkplain NixPsiUtil#isLegacyLet(NixExprAttrs) legacy let expression} + *
    + *
+ * These two cases are only implemented as one interface because + * the implementation is effectively the same in case of {@link NixExprLet} and {@link NixExprAttrs}. + */ +public interface NixDeclarationHost extends NixPsiElement { + /** + * Whether declarations of this element may be accessible as variables. + * If this method returns {@code true}, {@link #getSymbolForScope(String)} may be called to resolve a variable. + * + * @return {@code true} if the declarations shall be added to the scope. + */ + boolean isDeclaringVariables(); + + /** + * Returns the symbol for the given variable name. + * Must not be called when {@link #isDeclaringVariables()} returns {@code false}. + * Symbols exposed via this method become available from {@link #getScope()} in all children. + * The method returns {@code null} if no variable with the given name is declared from this element. + * + * @param variableName The name of the variable. + * @return The symbol representing the variable, or {@code null}. + */ + @Nullable NixUserSymbol getSymbolForScope(@NotNull String variableName); + + @Nullable NixUserSymbol getSymbol(@NotNull List attributePath); + + @NotNull Collection getDeclarations(@NotNull List attributePath); +} diff --git a/src/main/java/org/nixos/idea/psi/NixPsiElement.java b/src/main/java/org/nixos/idea/psi/NixPsiElement.java index c50475df..5d6b1fa9 100644 --- a/src/main/java/org/nixos/idea/psi/NixPsiElement.java +++ b/src/main/java/org/nixos/idea/psi/NixPsiElement.java @@ -2,7 +2,23 @@ import com.intellij.psi.PsiElement; import org.jetbrains.annotations.NotNull; +import org.nixos.idea.lang.references.NixSymbolDeclaration; +import org.nixos.idea.lang.references.NixSymbolReference; +import org.nixos.idea.lang.references.Scope; + +import java.util.Collection; public interface NixPsiElement extends PsiElement { + + @NotNull Scope getScope(); + + @Override + @SuppressWarnings("UnstableApiUsage") + @NotNull Collection getOwnDeclarations(); + + @Override + @SuppressWarnings("UnstableApiUsage") + @NotNull Collection getOwnReferences(); + T accept(@NotNull NixElementVisitor visitor); } diff --git a/src/main/java/org/nixos/idea/psi/NixPsiUtil.java b/src/main/java/org/nixos/idea/psi/NixPsiUtil.java index 072f5d3d..604c3473 100644 --- a/src/main/java/org/nixos/idea/psi/NixPsiUtil.java +++ b/src/main/java/org/nixos/idea/psi/NixPsiUtil.java @@ -1,6 +1,11 @@ package org.nixos.idea.psi; +import com.intellij.openapi.diagnostic.Logger; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.nixos.idea.psi.impl.NixStdAttrImpl; +import org.nixos.idea.psi.impl.NixStringAttrImpl; +import org.nixos.idea.util.NixStringUtil; import java.util.AbstractCollection; import java.util.Collection; @@ -10,6 +15,8 @@ public final class NixPsiUtil { + private static final Logger LOG = Logger.getInstance(NixPsiUtil.class); + private NixPsiUtil() {} // Cannot be instantiated public static boolean isRecursive(@NotNull NixExprAttrs attrs) { @@ -17,7 +24,11 @@ public static boolean isRecursive(@NotNull NixExprAttrs attrs) { attrs.getNode().findChildByType(NixTypes.LET) != null; } - public static Collection getParameters(@NotNull NixExprLambda lambda) { + public static boolean isLegacyLet(@NotNull NixExprAttrs attrs) { + return attrs.getNode().findChildByType(NixTypes.LET) != null; + } + + public static @NotNull Collection getParameters(@NotNull NixExprLambda lambda) { NixArgument mainParam = lambda.getArgument(); NixFormals formalsHolder = lambda.getFormals(); List formals = formalsHolder == null ? List.of() : formalsHolder.getFormalList(); @@ -41,4 +52,27 @@ public int size() { } }; } + + /** + * Returns the static name of an attribute. + * Is {@code null} for dynamic attributes. + * + * @param attr the attribute + * @return the name of the attribute or {@code null} + */ + public static @Nullable String getAttributeName(@NotNull NixAttr attr) { + if (attr instanceof NixStdAttrImpl) { + return attr.getText(); + } else if (attr instanceof NixStringAttrImpl stringAttr) { + NixStdString string = stringAttr.getStdString(); + List stringParts = string == null ? null : string.getStringParts(); + return stringParts != null && stringParts.size() == 1 && + stringParts.get(0) instanceof NixStringText text + ? NixStringUtil.parse(text) + : null; + } else { + LOG.error("Unexpected NixAttr implementation: " + attr.getClass()); + return null; + } + } } diff --git a/src/main/java/org/nixos/idea/psi/impl/AbstractNixDeclarationHost.java b/src/main/java/org/nixos/idea/psi/impl/AbstractNixDeclarationHost.java new file mode 100644 index 00000000..cc1d06be --- /dev/null +++ b/src/main/java/org/nixos/idea/psi/impl/AbstractNixDeclarationHost.java @@ -0,0 +1,250 @@ +package org.nixos.idea.psi.impl; + +import com.intellij.lang.ASTNode; +import com.intellij.openapi.diagnostic.Logger; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.nixos.idea.lang.references.NixSymbolDeclaration; +import org.nixos.idea.lang.references.symbol.NixUserSymbol; +import org.nixos.idea.psi.NixAttr; +import org.nixos.idea.psi.NixAttrPath; +import org.nixos.idea.psi.NixBind; +import org.nixos.idea.psi.NixBindInherit; +import org.nixos.idea.psi.NixDeclarationHost; +import org.nixos.idea.psi.NixExprAttrs; +import org.nixos.idea.psi.NixExprLambda; +import org.nixos.idea.psi.NixExprLet; +import org.nixos.idea.psi.NixIdentifier; +import org.nixos.idea.psi.NixParameter; +import org.nixos.idea.psi.NixPsiElement; +import org.nixos.idea.psi.NixPsiUtil; +import org.nixos.idea.settings.NixSymbolSettings; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Implementation of all instances of {@link NixDeclarationHost}. + */ +abstract class AbstractNixDeclarationHost extends AbstractNixPsiElement implements NixDeclarationHost { + + private static final Logger LOG = Logger.getInstance(AbstractNixDeclarationHost.class); + + private @Nullable Symbols mySymbols; + + AbstractNixDeclarationHost(@NotNull ASTNode node) { + super(node); + if (!(this instanceof NixExprLet) && !(this instanceof NixExprAttrs) && !(this instanceof NixExprLambda)) { + LOG.error("Unknown subclass: " + getClass()); + } + } + + @Override + public final boolean isDeclaringVariables() { + if (this instanceof NixExprLet || this instanceof NixExprLambda) { + return true; + } else if (this instanceof NixExprAttrs set) { + return NixPsiUtil.isRecursive(set); + } else { + LOG.error("Unknown subclass: " + getClass()); + return false; + } + } + + @Override + public @Nullable NixUserSymbol getSymbolForScope(@NotNull String variableName) { + // Note: When we want to support dynamic attributes in the future, they must be ignored by this method. + assert isDeclaringVariables() : "getSymbolForScope(...) must not be called when isDeclaringVariables() returns false"; + return getSymbols().getSymbolForScope(variableName); + } + + @Override + public final @Nullable NixUserSymbol getSymbol(@NotNull List attributePath) { + return getSymbols().getSymbol(attributePath); + } + + @Override + public final @NotNull Collection getDeclarations(@NotNull List attributePath) { + return getSymbols().getDeclarations(attributePath); + } + + static @NotNull Collection getDeclarations(@NotNull AbstractNixPsiElement element) { + AbstractNixDeclarationHost declarationHost = element.getDeclarationHost(); + if (declarationHost == null) { + return List.of(); + } + return declarationHost.getSymbols().getDeclarations(element); + } + + private @NotNull Symbols getSymbols() { + NixSymbolSettings settings = NixSymbolSettings.getInstance(); + Symbols symbols = mySymbols; + if (symbols == null || symbols.isOutdated(settings)) { + MY_SYMBOLS.compareAndSet(this, symbols, initSymbols(settings)); + Objects.requireNonNull(mySymbols, "initSymbols() must not return null"); + } + return mySymbols; + } + + private @NotNull Symbols initSymbols(@NotNull NixSymbolSettings settings) { + Symbols symbols = new Symbols(settings); + if (!NixSymbolSettings.getInstance().getEnabled()) { + return symbols; + } else if (this instanceof NixExprLet let) { + collectBindDeclarations(symbols, let.getBindList(), true); + } else if (this instanceof NixExprAttrs attrs) { + collectBindDeclarations(symbols, attrs.getBindList(), NixPsiUtil.isLegacyLet(attrs)); + } else if (this instanceof NixExprLambda lambda) { + for (NixParameter parameter : NixPsiUtil.getParameters(lambda)) { + NixIdentifier identifier = parameter.getIdentifier(); + symbols.addParameter(parameter, identifier); + } + } else { + LOG.error("Unknown subclass: " + getClass()); + } + return symbols; + } + + private void collectBindDeclarations(@NotNull Symbols result, @NotNull List bindList, boolean isVariable) { + NixUserSymbol.Type type = isVariable ? NixUserSymbol.Type.VARIABLE : NixUserSymbol.Type.ATTRIBUTE; + for (NixBind bind : bindList) { + if (bind instanceof NixBindAttrImpl bindAttr) { + result.addBindAttr(bindAttr, bindAttr.getAttrPath(), type); + } else if (bind instanceof NixBindInherit bindInherit) { + for (NixAttr inheritedAttribute : bindInherit.getAttrList()) { + result.addInherit(bindInherit, inheritedAttribute, type, bindInherit.getExpr() != null); + } + } else { + LOG.error("Unexpected NixBind implementation: " + bind.getClass()); + } + } + } + + private boolean checkDeclarationHost(@NotNull NixPsiElement element) { + if (element instanceof AbstractNixPsiElement el) { + if (el.getDeclarationHost() == this) { + return true; + } + LOG.error("Element must belong to this declaration host"); + } else { + LOG.error("Unexpected NixPsiElement implementation: " + element.getClass()); + } + return false; + } + + private final class Symbols { + private final @NotNull Map, NixUserSymbol> mySymbols = new HashMap<>(); + private final @NotNull Map, List> myDeclarationsBySymbol = new HashMap<>(); + private final @NotNull Map> myDeclarationsByElement = new HashMap<>(); + private final @NotNull Set myVariables = new HashSet<>(); + private final long mySettingsModificationCount; + + private Symbols(@NotNull NixSymbolSettings settings) { + mySettingsModificationCount = settings.getStateModificationCount(); + } + + private boolean isOutdated(@NotNull NixSymbolSettings settings) { + return mySettingsModificationCount != settings.getStateModificationCount(); + } + + private void addBindAttr(@NotNull NixPsiElement element, @NotNull NixAttrPath attrPath, @NotNull NixUserSymbol.Type type) { + if (!checkDeclarationHost(element)) { + return; + } + + String elementName = attrPath.getText(); + List path = new ArrayList<>(); + for (NixAttr attr : attrPath.getAttrList()) { + String name = NixPsiUtil.getAttributeName(attr); + if (name == null) { + return; + } + path.add(name); + add(element, attr, path, type, true, elementName, null); + type = NixUserSymbol.Type.ATTRIBUTE; + } + } + + private void addInherit(@NotNull NixPsiElement element, @NotNull NixAttr attr, + @NotNull NixUserSymbol.Type type, boolean exposeAsVariable) { + String name = NixPsiUtil.getAttributeName(attr); + if (checkDeclarationHost(element) && name != null) { + add(element, attr, List.of(name), + type, exposeAsVariable, attr.getText(), "inherit"); + } + } + + private void addParameter(@NotNull NixPsiElement element, @NotNull NixIdentifier identifier) { + if (checkDeclarationHost(element)) { + add(element, identifier, + List.of(identifier.getText()), + NixUserSymbol.Type.PARAMETER, true, + identifier.getText(), "lambda"); + } + } + + private void add(@NotNull NixPsiElement element, + @NotNull NixPsiElement identifier, + @NotNull List attributePath, + @NotNull NixUserSymbol.Type type, + boolean exposeAsVariable, + @NotNull String elementName, + @Nullable String elementType) { + assert checkDeclarationHost(element); + List attributePathCopy = List.copyOf(attributePath); + + NixUserSymbol symbol = mySymbols.computeIfAbsent(attributePathCopy, + path -> new NixUserSymbol(AbstractNixDeclarationHost.this, path, type)); + if (exposeAsVariable && attributePath.size() == 1) { + myVariables.add(attributePath.get(0)); + } + + NixSymbolDeclaration declaration = new NixSymbolDeclaration(element, identifier, symbol, elementName, elementType); + myDeclarationsBySymbol.computeIfAbsent(attributePathCopy, __ -> new ArrayList<>()) + .add(declaration); + myDeclarationsByElement.computeIfAbsent(element, __ -> new ArrayList<>()) + .add(declaration); + } + + private @Nullable NixUserSymbol getSymbolForScope(@NotNull String variableName) { + return myVariables.contains(variableName) ? mySymbols.get(List.of(variableName)) : null; + } + + private @Nullable NixUserSymbol getSymbol(@NotNull List attributePath) { + return mySymbols.get(attributePath); + } + + private @NotNull List getDeclarations(@NotNull List attributePath) { + return myDeclarationsBySymbol.getOrDefault(attributePath, List.of()); + } + + private @NotNull List getDeclarations(@NotNull NixPsiElement element) { + return myDeclarationsByElement.getOrDefault(element, List.of()); + } + } + + @Override + public void subtreeChanged() { + super.subtreeChanged(); + mySymbols = null; + } + + // VarHandle mechanics + private static final VarHandle MY_SYMBOLS; + static { + try { + MethodHandles.Lookup l = MethodHandles.lookup(); + MY_SYMBOLS = l.findVarHandle(AbstractNixDeclarationHost.class, "mySymbols", Symbols.class); + } catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } +} diff --git a/src/main/java/org/nixos/idea/psi/impl/AbstractNixPsiElement.java b/src/main/java/org/nixos/idea/psi/impl/AbstractNixPsiElement.java index bc33025c..dc49ef1b 100644 --- a/src/main/java/org/nixos/idea/psi/impl/AbstractNixPsiElement.java +++ b/src/main/java/org/nixos/idea/psi/impl/AbstractNixPsiElement.java @@ -2,13 +2,89 @@ import com.intellij.extapi.psi.ASTWrapperPsiElement; import com.intellij.lang.ASTNode; +import com.intellij.openapi.util.Key; +import com.intellij.psi.util.CachedValue; +import com.intellij.psi.util.CachedValueProvider; +import com.intellij.psi.util.CachedValuesManager; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.nixos.idea.lang.references.NixScopeReference; +import org.nixos.idea.lang.references.NixSymbolDeclaration; +import org.nixos.idea.lang.references.NixSymbolReference; +import org.nixos.idea.lang.references.Scope; +import org.nixos.idea.psi.NixBindInherit; +import org.nixos.idea.psi.NixExpr; +import org.nixos.idea.psi.NixExprSelect; +import org.nixos.idea.psi.NixExprVar; import org.nixos.idea.psi.NixPsiElement; +import org.nixos.idea.psi.NixPsiUtil; +import org.nixos.idea.settings.NixSymbolSettings; + +import java.util.Collection; +import java.util.List; +import java.util.stream.Stream; abstract class AbstractNixPsiElement extends ASTWrapperPsiElement implements NixPsiElement { + private static final Key> KEY_DECLARATION_HOST = Key.create("AbstractNixPsiElement.declarationHost"); + private static final Key> KEY_SCOPE = Key.create("AbstractNixPsiElement.scope"); + AbstractNixPsiElement(@NotNull ASTNode node) { super(node); } + @Override + public final @NotNull Scope getScope() { + return CachedValuesManager.getCachedValue(this, KEY_SCOPE, () -> { + Scope parentScope = getParent() instanceof NixPsiElement parent ? parent.getScope() : Scope.root(); + Scope result = Scope.subScope(parentScope, this); + return CachedValueProvider.Result.create(result, this); + }); + } + + @Override + @SuppressWarnings("UnstableApiUsage") + public final @NotNull Collection getOwnDeclarations() { + return AbstractNixDeclarationHost.getDeclarations(this); + } + + final @Nullable AbstractNixDeclarationHost getDeclarationHost() { + return CachedValuesManager.getCachedValue(this, KEY_DECLARATION_HOST, () -> { + AbstractNixDeclarationHost result = this instanceof AbstractNixDeclarationHost host ? host + : getParent() instanceof AbstractNixPsiElement parent ? parent.getDeclarationHost() + : null; + return CachedValueProvider.Result.create(result, this); + }); + } + + @Override + @SuppressWarnings("UnstableApiUsage") + public final @NotNull Collection getOwnReferences() { + if (!NixSymbolSettings.getInstance().getEnabled()) { + return List.of(); + } else if (this instanceof NixExprVar) { + return List.of(new NixScopeReference(this, this, getText())); + } else if (this instanceof NixExprSelect) { + // TODO: Attribute reference support + return List.of(); + } else if (this instanceof NixBindInherit bindInherit) { + NixExpr accessedObject = bindInherit.getExpr(); + if (accessedObject == null) { + return bindInherit.getAttrList().stream().flatMap(attr -> { + String variableName = NixPsiUtil.getAttributeName(attr); + if (variableName == null) { + return Stream.empty(); + } else { + return Stream.of(new NixScopeReference(this, attr, variableName)); + } + }).toList(); + } else { + // TODO: Attribute reference support + return List.of(); + } + } else { + return List.of(); + } + } + } diff --git a/src/main/java/org/nixos/idea/settings/NixStoragePaths.java b/src/main/java/org/nixos/idea/settings/NixStoragePaths.java index 5363cefc..1e128c7b 100644 --- a/src/main/java/org/nixos/idea/settings/NixStoragePaths.java +++ b/src/main/java/org/nixos/idea/settings/NixStoragePaths.java @@ -9,7 +9,13 @@ public final class NixStoragePaths { /** - * Location and configuration of external tools. + * Storage location of non-system dependent settings for this plugin. + * This constant must be used with {@link RoamingType#DEFAULT}. + */ + public static final String DEFAULT = "nix-idea.xml"; + + /** + * Storage location of settings for external tools. * The settings in the file are considered system dependent. * This constant must be used with {@link RoamingType#LOCAL}. */ diff --git a/src/main/java/org/nixos/idea/settings/NixSymbolConfigurable.kt b/src/main/java/org/nixos/idea/settings/NixSymbolConfigurable.kt new file mode 100644 index 00000000..9c4a79bb --- /dev/null +++ b/src/main/java/org/nixos/idea/settings/NixSymbolConfigurable.kt @@ -0,0 +1,67 @@ +package org.nixos.idea.settings + +import com.intellij.openapi.options.BoundSearchableConfigurable +import com.intellij.openapi.options.Configurable +import com.intellij.openapi.ui.DialogPanel +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.dsl.builder.Cell +import com.intellij.ui.dsl.builder.bind +import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.dsl.builder.selected + +class NixSymbolConfigurable : + BoundSearchableConfigurable("Nix Symbols", "org.nixos.idea.settings.NixSymbolConfigurable"), + Configurable.Beta { + + override fun createPanel(): DialogPanel { + val settings = NixSymbolSettings.getInstance() + lateinit var enabledCheckBox: Cell + return panel { + row { + enabledCheckBox = checkBox("Use Symbol API to resolve references and find usages") + .bindSelected(settings::enabled) + } + rowsRange { + groupRowsRange("Go To Declaration") { + buttonsGroup { + row { + radioButton("Go to first declaration", true) + radioButton("Ask when symbol has multiple declarations", false) + }.rowComment( + """ + Attribute sets and let-expressions may contain + multiple indirect declarations of the same symbol. + """.trimIndent() + ).contextHelp( + """ + The following code block contains three + declarations of “common”: +
+                            let
+                              zero = 0;
+                              common.a = 1;
+                              common.b = 2;
+                              common.c = 3;
+                            in
+                              common
+                            
+ If you run Go To Declaration on the last line, + this setting defines whether + the action jumps directly to common.a + (the first declaration), + or opens a popup asking which declaration you want to see. + """.trimIndent() + ) + }.bind(settings::jumpToFirstDeclaration) + } + groupRowsRange("Find Usages") { + row { + checkBox("Show declarations as part of the results") + .bindSelected(settings::showDeclarationsAsUsages) + } + } + }.enabledIf(enabledCheckBox.selected) + } + } +} diff --git a/src/main/java/org/nixos/idea/settings/NixSymbolSettings.kt b/src/main/java/org/nixos/idea/settings/NixSymbolSettings.kt new file mode 100644 index 00000000..cf4df16c --- /dev/null +++ b/src/main/java/org/nixos/idea/settings/NixSymbolSettings.kt @@ -0,0 +1,30 @@ +package org.nixos.idea.settings + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.components.SimplePersistentStateComponent +import com.intellij.openapi.components.State +import com.intellij.openapi.components.Storage +import org.nixos.idea.settings.SimplePersistentStateComponentHelper.delegate + +@State(name = "NixSymbolSettings", storages = [Storage(NixStoragePaths.DEFAULT)]) +class NixSymbolSettings : SimplePersistentStateComponent(State()) { + + class State : BaseState() { + var enabledPreview by property(false) + var jumpToFirstDeclaration by property(false) + var showDeclarationsAsUsages by property(false) + } + + companion object { + @JvmStatic + fun getInstance(): NixSymbolSettings { + return ApplicationManager.getApplication().getService(NixSymbolSettings::class.java) + } + } + + var enabled: Boolean by delegate(State::enabledPreview) + var jumpToFirstDeclaration by delegate(State::jumpToFirstDeclaration) + var showDeclarationsAsUsages: Boolean by delegate(State::showDeclarationsAsUsages) + +} diff --git a/src/main/java/org/nixos/idea/settings/SimplePersistentStateComponentHelper.kt b/src/main/java/org/nixos/idea/settings/SimplePersistentStateComponentHelper.kt new file mode 100644 index 00000000..715ff279 --- /dev/null +++ b/src/main/java/org/nixos/idea/settings/SimplePersistentStateComponentHelper.kt @@ -0,0 +1,38 @@ +package org.nixos.idea.settings + +import com.intellij.openapi.components.BaseState +import com.intellij.openapi.components.SimplePersistentStateComponent +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KMutableProperty1 +import kotlin.reflect.KProperty + +internal object SimplePersistentStateComponentHelper { + + /** + * Creates property which delegates every access to the given property of the state. + * + * ```kotlin + * @State(name = "SomeSettings", storages = [Storage(...)]) + * class SomeSettings : SimplePersistentStateComponent(State()) { + * class State : BaseState() { + * // The internal storage of the configured values + * var enabled by property(true) + * } + * + * // Makes the property publicly accessible + * var enabled: Boolean by delegate(State::enabled) + * } + * ``` + */ + fun delegate(prop: KMutableProperty1): ReadWriteProperty, V> { + return object : ReadWriteProperty, V> { + override fun getValue(thisRef: SimplePersistentStateComponent, property: KProperty<*>): V { + return prop.get(thisRef.state) + } + + override fun setValue(thisRef: SimplePersistentStateComponent, property: KProperty<*>, value: V) { + prop.set(thisRef.state, value) + } + } + } +} diff --git a/src/main/java/org/nixos/idea/util/TextRangeFactory.java b/src/main/java/org/nixos/idea/util/TextRangeFactory.java new file mode 100644 index 00000000..b79c5750 --- /dev/null +++ b/src/main/java/org/nixos/idea/util/TextRangeFactory.java @@ -0,0 +1,47 @@ +package org.nixos.idea.util; + +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiElement; +import org.jetbrains.annotations.NotNull; +import org.nixos.idea.psi.NixPsiElement; + +/** + * Factory methods to construct {@link TextRange} instances from PSI elements. + */ +public final class TextRangeFactory { + + private TextRangeFactory() {} // Cannot be instantiated + + /** + * Creates {@link TextRange} for an element relative to itself. + * + * @param element The element for which to create the range. + * @return The range with an offset of zero and a length of the given element. + */ + public static @NotNull TextRange root(@NotNull NixPsiElement element) { + return TextRange.from(0, element.getTextLength()); + } + + /** + * Creates {@link TextRange} for an element relative to the given parent. + * + * @param element The element for which to create the range. + * @param parent The parent element which becomes the reference frame for the returned range. + * @return The range of the given {@code element} relative to the given {@code parent}. + */ + public static @NotNull TextRange relative(@NotNull NixPsiElement element, @NotNull NixPsiElement parent) { + assert isChild(element, parent) : element + " not a child of " + parent; + int offset = element.getNode().getStartOffset() - parent.getNode().getStartOffset(); + return TextRange.from(offset, element.getTextLength()); + } + + private static boolean isChild(@NotNull NixPsiElement child, @NotNull NixPsiElement parent) { + for (PsiElement current = child; current != null; current = current.getParent()) { + if (current == parent) { + return true; + } + } + return false; + } + +} diff --git a/src/main/lang/Nix.bnf b/src/main/lang/Nix.bnf index 5aba71c7..121d3302 100644 --- a/src/main/lang/Nix.bnf +++ b/src/main/lang/Nix.bnf @@ -24,6 +24,10 @@ // no one really needs to know that + - * / are expected at any offset consumeTokenMethod("expr_op.*")="consumeTokenFast" + // Declaration hosts + implements("expr_let|expr_attrs|expr_lambda")="org.nixos.idea.psi.NixDeclarationHost" + mixin("expr_let|expr_attrs|expr_lambda")="org.nixos.idea.psi.impl.AbstractNixDeclarationHost" + tokens = [ // This list does not contain all tokens. This list only defines the debug // names for tokens which have a distinct text representation. Other tokens diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index aeba9f09..0f24bfd2 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -35,12 +35,24 @@ language="Nix" implementationClass="org.nixos.idea.lang.NixCommenter"/> + + + + + + diff --git a/src/test/java/org/nixos/idea/_testutil/IdeaPlatformExtension.java b/src/test/java/org/nixos/idea/_testutil/IdeaPlatformExtension.java index 350a8850..8ee44ae3 100644 --- a/src/test/java/org/nixos/idea/_testutil/IdeaPlatformExtension.java +++ b/src/test/java/org/nixos/idea/_testutil/IdeaPlatformExtension.java @@ -3,26 +3,39 @@ import com.intellij.openapi.Disposable; import com.intellij.openapi.module.Module; import com.intellij.openapi.project.Project; +import com.intellij.openapi.util.io.FileUtil; +import com.intellij.openapi.util.text.StringUtil; import com.intellij.testFramework.EdtTestUtil; import com.intellij.testFramework.PlatformTestUtil; +import com.intellij.testFramework.fixtures.CodeInsightTestFixture; import com.intellij.testFramework.fixtures.IdeaProjectTestFixture; +import com.intellij.testFramework.fixtures.IdeaTestExecutionPolicy; import com.intellij.testFramework.fixtures.IdeaTestFixture; import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory; +import org.junit.jupiter.api.Named; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.ExtensionContext.Namespace; import org.junit.jupiter.api.extension.ExtensionContext.Store.CloseableResource; import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; import org.junit.jupiter.api.extension.ParameterResolver; import org.junit.jupiter.api.extension.TestInstanceFactoryContext; import org.junit.jupiter.api.extension.TestInstancePreConstructCallback; import org.junit.jupiter.api.extension.TestInstancePreDestroyCallback; +import org.junit.platform.commons.support.AnnotationSupport; import java.util.Map; +import java.util.Objects; import java.util.function.Function; final class IdeaPlatformExtension implements ParameterResolver, TestInstancePreConstructCallback, TestInstancePreDestroyCallback { + private static final Namespace NAMESPACE = Namespace.create(IdeaPlatformExtension.class); + private static final Named KEY_FIXTURE = Named.of("KEY_FIXTURE", new Object()); + private static final Named KEY_FIXTURE_CLOSABLE = Named.of("KEY_FIXTURE_CLOSABLE", new Object()); + private static final Map, Function> PARAMETER_FACTORIES = Map.ofEntries( + createResolver(CodeInsightTestFixture.class, IdeaPlatformExtension::resolveCodeInsightFixture), createResolver(IdeaProjectTestFixture.class, IdeaPlatformExtension::resolveFixture), createResolver(IdeaTestFixture.class, IdeaPlatformExtension::resolveFixture), createResolver(Project.class, IdeaPlatformExtension::resolveProject), @@ -32,20 +45,30 @@ final class IdeaPlatformExtension implements ParameterResolver, TestInstancePreC @Override public void preConstructTestInstance(TestInstanceFactoryContext factoryContext, ExtensionContext context) throws Exception { - context.getStore(NAMESPACE).put(FixtureClosableWrapper.class, new FixtureClosableWrapper(context)); + FixtureClosableWrapper existing = context.getStore(NAMESPACE).get(KEY_FIXTURE, FixtureClosableWrapper.class); + if (existing == null) { + context.getStore(NAMESPACE).put(KEY_FIXTURE, new FixtureClosableWrapper(context)); + context.getStore(NAMESPACE).put(KEY_FIXTURE_CLOSABLE, Boolean.TRUE); + } else { + context.getStore(NAMESPACE).put(KEY_FIXTURE, existing); + } } @Override public void preDestroyTestInstance(ExtensionContext context) throws Exception { // Unfortunately, the ExtensionContext given to `preConstructTestInstance` is not scoped to the individual test. - // We therefore have cleanup the context manually. + // We therefore must clean up the context manually. // https://github.com/junit-team/junit5/issues/3445 FixtureClosableWrapper wrapper; - do { - wrapper = context.getStore(NAMESPACE).remove(FixtureClosableWrapper.class, FixtureClosableWrapper.class); + while (context != null) { + wrapper = context.getStore(NAMESPACE).remove(KEY_FIXTURE, FixtureClosableWrapper.class); + if (wrapper != null) { + if (context.getStore(NAMESPACE).remove(KEY_FIXTURE_CLOSABLE, Boolean.class) == Boolean.TRUE) { + wrapper.close(); + } + } context = context.getParent().orElse(null); - } while (context != null && wrapper == null); - if (wrapper != null) wrapper.close(); + } } @Override @@ -58,8 +81,16 @@ public Object resolveParameter(ParameterContext parameterContext, ExtensionConte return PARAMETER_FACTORIES.get(parameterContext.getParameter().getType()).apply(extensionContext); } + private static CodeInsightTestFixture resolveCodeInsightFixture(ExtensionContext context) { + if (resolveFixture(context) instanceof CodeInsightTestFixture fixture) { + return fixture; + } else { + throw new ParameterResolutionException("cannot resolve CodeInsightTestFixture. @WithIdeaPlatform.CodeInsight is not specified for this test."); + } + } + private static IdeaProjectTestFixture resolveFixture(ExtensionContext context) { - return context.getStore(NAMESPACE).get(FixtureClosableWrapper.class, FixtureClosableWrapper.class).myFixture; + return context.getStore(NAMESPACE).get(KEY_FIXTURE, FixtureClosableWrapper.class).myFixture; } private static Project resolveProject(ExtensionContext context) { @@ -84,7 +115,17 @@ private static final class FixtureClosableWrapper implements CloseableResource { private FixtureClosableWrapper(ExtensionContext context) throws Exception { String testName = PlatformTestUtil.getTestName(context.getDisplayName(), false); IdeaTestFixtureFactory factory = IdeaTestFixtureFactory.getFixtureFactory(); - myFixture = factory.createLightFixtureBuilder(testName).getFixture(); + IdeaProjectTestFixture baseFixture = factory.createLightFixtureBuilder(testName).getFixture(); + myFixture = AnnotationSupport.findAnnotation(context.getTestMethod(), WithIdeaPlatform.CodeInsight.class) + .or(() -> AnnotationSupport.findAnnotation(context.getTestClass(), WithIdeaPlatform.CodeInsight.class)) + .map(annotation -> { + IdeaTestExecutionPolicy policy = Objects.requireNonNull(IdeaTestExecutionPolicy.current()); + CodeInsightTestFixture fixture = factory.createCodeInsightFixture(baseFixture, policy.createTempDirTestFixture()); + fixture.setTestDataPath(StringUtil.trimEnd(FileUtil.toSystemIndependentName(policy.getHomePath()), "/") + '/' + + StringUtil.trimStart(FileUtil.toSystemIndependentName(annotation.basePath()), "/")); + return fixture; + }) + .orElse(baseFixture); myFixture.setUp(); } diff --git a/src/test/java/org/nixos/idea/_testutil/Markers.java b/src/test/java/org/nixos/idea/_testutil/Markers.java new file mode 100644 index 00000000..abf298d4 --- /dev/null +++ b/src/test/java/org/nixos/idea/_testutil/Markers.java @@ -0,0 +1,561 @@ +package org.nixos.idea._testutil; + +import com.intellij.openapi.command.WriteCommandAction; +import com.intellij.openapi.editor.Document; +import com.intellij.openapi.util.Computable; +import com.intellij.openapi.util.IntRef; +import com.intellij.openapi.util.TextRange; +import com.intellij.psi.PsiDocumentManager; +import com.intellij.psi.PsiFile; +import com.intellij.testFramework.fixtures.CodeInsightTestFixture; +import com.intellij.util.DocumentUtil; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; + +import java.util.AbstractCollection; +import java.util.AbstractList; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.Deque; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Text with XML-style markers. + *
{@code
+ * The quick fox jumps over the lazy dog.
+ * }
+ * Before you can create an instance of this class, you must declare the different types of markers. + *
{@code
+ * TagName TAG_NOUN = Markers.tagName("noun");
+ * TagName TAG_ADJECTIVE = Markers.tagName("adjective");
+ * }
+ * Afterward, you can parse the text from above using {@link #parse(String, TagName...)}. + * You can also use {@link #create(String)} and {@link #withMarkers(Collection, Stream)} to create an instance from known ranges. + * When you have created an instance of this class, you can use it to obtain the positions of the different markers, + * or compare it with another instance using {@link #equals(Object)}. + *
{@code
+ * void testNounDetection(String markedText) {
+ *     Markers markers = Markers.parse(markedText, TAG_NOUN, TAG_ADJECTIVE);
+ *     Collection detectedNouns = runNounDetection(markers.unmarkedText());
+ *     assertEquals(markers.markers(TAG_NOUN), Markers.create(markers.unmarkedText(), TAG_NOUN, detectedNouns));
+ * }}
+ */ +public final class Markers extends AbstractCollection { + + private final @NotNull String myText; + private final @NotNull Set myKnownTagNames; + private final @NotNull List myMarkers; + + private Markers(@NotNull String text, @NotNull Set tagNames, @NotNull Stream markers) { + myText = text; + myKnownTagNames = Set.copyOf(tagNames); + myMarkers = markers + .sorted(Comparator.comparingInt(Marker::start) + .thenComparing(Comparator.comparingInt(Marker::end).reversed())) + .toList(); + } + + //region Factory methods + + /** + * Declares a new type of marker. + * + * @param tagName The name used for the tags of the marker. + * @return The type of the marker. + * @see #tagNameVoid(String) + */ + @Contract(value = "_ -> new", pure = true) + public static @NotNull TagName tagName(@NotNull String tagName) { + return new TagName(tagName, true); + } + + /** + * Declares a new type of marker. + * In contrast to {@link #tagName(String)}, this marker can not have any content. + * They behave similar to void elements in HTML. + * They must not have an end-tag and only represent a single offset instead of a range. + * + * @param tagName The name used for the tags of the marker. + * @return The type of the marker. + */ + @Contract(value = "_ -> new", pure = true) + public static @NotNull TagName tagNameVoid(@NotNull String tagName) { + return new TagName(tagName, false); + } + + @Contract(value = "_, _ -> new", pure = true) + public static @NotNull Marker marker(@NotNull TagName tagName, int offset) { + return new Marker(tagName, offset, offset); + } + + @Contract(value = "_, _, _ -> new", pure = true) + public static @NotNull Marker marker(@NotNull TagName tagName, int start, int end) { + return new Marker(tagName, start, end); + } + + @Contract(value = "_, _ -> new", pure = true) + public static @NotNull Marker marker(@NotNull TagName tagName, @NotNull TextRange range) { + return new Marker(tagName, range.getStartOffset(), range.getEndOffset()); + } + + @Contract(value = "_ -> new", pure = true) + public static @NotNull Markers create(@NotNull String text) { + return new Markers(text, Set.of(), Stream.empty()); + } + + @Contract(value = "_, _, _ -> new", pure = true) + public static @NotNull Markers create(@NotNull String text, @NotNull TagName tagName, @NotNull Collection ranges) { + return Markers.create(text).withMarkers(tagName, ranges); + } + + @Contract(value = "_, _, _ -> new", pure = true) + public static @NotNull Markers create(@NotNull String text, @NotNull Collection markers, @NotNull TagName... tagNames) { + return create(text, markers.stream(), tagNames); + } + + @Contract(value = "_, _, _ -> new", pure = true) + public static @NotNull Markers create(@NotNull String text, @NotNull Stream markers, @NotNull TagName... tagNames) { + return Markers.create(text).withMarkers(markers, tagNames); + } + + @Contract(value = "_, _ -> new", pure = true) + public static @NotNull Markers parse(@NotNull String input, @NotNull TagName... tagNames) { + List tokens = tokenize(input, tagNames); + return parse(tokens, tagNames); + } + + public static @NotNull Markers extract(@NotNull CodeInsightTestFixture fixture, @NotNull PsiFile file, @NotNull TagName... tagNames) { + return extract(fixture, fixture.getDocument(file), tagNames); + } + + /** + * Extracts the markers from the given document. + * In contrast to {@link #parse(String, TagName...)}, + * this method modifies the given document by removing the markers. + * After this method call, the given document will contain the same text as returned by {@link #unmarkedText()}. + * + * @param fixture The test fixture which holds the given document. This instance is used to commit the document and update the PSI nodes. + * @param document The document which contains the marked text. + * @param tagNames The marker types which shall be extracted. + * @return A new instance of this class representing the marked text provided by the given document. + * @see #extract(CodeInsightTestFixture, PsiFile, TagName...) + */ + public static @NotNull Markers extract(@NotNull CodeInsightTestFixture fixture, @NotNull Document document, @NotNull TagName... tagNames) { + return WriteCommandAction.runWriteCommandAction(fixture.getProject(), (Computable) () -> { + List tokens = tokenize(document.getText(), tagNames); + DocumentUtil.executeInBulk(document, () -> { + int offset = 0; + for (Token token : tokens) { + if (token instanceof Tag) { + document.deleteString(offset, offset + token.originalSize()); + } else { + offset += token.originalSize(); + } + } + }); + PsiDocumentManager.getInstance(fixture.getProject()).commitAllDocuments(); + return parse(tokens, tagNames); + }); + } + + @Contract(value = "_, _ -> new", pure = true) + private static @NotNull List tokenize(@NotNull String input, @NotNull TagName... tagNames) { + Map tags = Arrays.stream(tagNames) + .collect(Collectors.toUnmodifiableMap(tagName -> tagName.myName, Function.identity())); + List result = new ArrayList<>(); + Matcher matcher = Pattern.compile("<(?/)?(?" + TagName.PATTERN_NAME.pattern() + ")\\s*(?/)?>").matcher(input); + int lastTagEnd = 0; + for (int offset = 0; matcher.find(offset); offset = matcher.end()) { + boolean close = matcher.group("close") != null; + boolean selfClose = matcher.group("selfClose") != null; + TagName tagName = tags.get(matcher.group("name")); + if (tagName == null || close && selfClose) { + continue; + } + + if (matcher.start() != lastTagEnd) { + assert matcher.start() > lastTagEnd; + result.add(new Text(input.substring(lastTagEnd, matcher.start()))); + } + + Tag.Type type = close ? Tag.Type.CLOSE : selfClose ? Tag.Type.SINGLE : Tag.Type.OPEN; + result.add(new Tag(tagName, type, matcher.end() - matcher.start())); + lastTagEnd = matcher.end(); + } + if (lastTagEnd != input.length()) { + assert lastTagEnd < input.length(); + result.add(new Text(input.substring(lastTagEnd))); + } + return result; + } + + @Contract(value = "_, _ -> new", pure = true) + private static @NotNull Markers parse(@NotNull List tokens, @NotNull TagName[] tagNames) { + // Empty containers for the result + StringBuilder text = new StringBuilder(); + List markers = new ArrayList<>(); + // Parse tokens + Deque> tagStack = new ArrayDeque<>(); + int sourceOffset = 0; + int textOffset = 0; + for (Token token : tokens) { + if (token instanceof Text textToken) { + text.append(textToken); + textOffset += textToken.originalSize(); + } else if (token instanceof Tag tag) { + Tag.Type type = tag.effectiveType(); + switch (type) { + case SINGLE -> markers.add(new Marker(tag.name(), textOffset, textOffset)); + case OPEN -> tagStack.add(new Indexed<>(textOffset, tag)); + case CLOSE -> { + TagName name = tag.name(); + Indexed openingTag = tagStack.removeLast(); + if (openingTag.item().name() == name) { + markers.add(new Marker(name, openingTag.index(), textOffset)); + } else { + throw new IllegalStateException("Non-matching closing tag at offset " + sourceOffset + ": " + tag); + } + } + } + } else { + throw new IllegalStateException("Unexpected token class: " + token.getClass()); + } + sourceOffset += token.originalSize(); + } + if (!tagStack.isEmpty()) { + throw new IllegalStateException("The following tags have not been closed: " + tagStack); + } + return new Markers(text.toString(), Set.of(tagNames), markers.stream()); + } + + //endregion + //region Result getter + + /** + * Returns the text without and marker. + * + * @return the text without any of the markers. + */ + public @NotNull String unmarkedText() { + return myText; + } + + /** + * Returns a new instance with all other markers removed. + * The new instance only preserves the markers of the given types. + * + * @param tagNames The marker types you are interested in. + * @return Marked text containing only the given marker types. + */ + @Contract(value = "_ -> new", pure = true) + public @NotNull Markers markers(@NotNull TagName... tagNames) { + Set newTagNames = Set.of(tagNames); + if (!myKnownTagNames.containsAll(newTagNames)) { + throw new IllegalStateException("At least one unknown TagName: " + Arrays.toString(tagNames)); + } + return new Markers(myText, newTagNames, myMarkers.stream().filter(marker -> newTagNames.contains(marker.myTagName))); + } + + @Contract(value = "_, _ -> new", pure = true) + public @NotNull Markers withMarkers(@NotNull TagName tagName, @NotNull Collection ranges) { + return withMarkers(List.of(tagName), ranges.stream() + .map(range -> new Marker(tagName, range.getStartOffset(), range.getEndOffset()))); + } + + @Contract(value = "_, _ -> new", pure = true) + public @NotNull Markers withMarkers(@NotNull Stream markers, @NotNull TagName... tagNames) { + return withMarkers(Arrays.asList(tagNames), markers); + } + + /** + * Returns a new instance with the given markers added. + * The new instance contains all the markers form this instance, and all the markers added. + * + * @param tagNames The types of the markers which may be added. + * @param markers The new markers. + * @return Marked text containing only the given marker types. + * @see #withMarkers(TagName, Collection) + * @see #withMarkers(Stream, TagName...) + */ + @Contract(value = "_, _ -> new", pure = true) + public @NotNull Markers withMarkers(@NotNull Collection tagNames, @NotNull Stream markers) { + Set newTagNames = Stream.concat(myKnownTagNames.stream(), tagNames.stream()) + .collect(Collectors.toUnmodifiableSet()); + return new Markers(myText, newTagNames, Stream.concat(myMarkers.stream(), markers.peek(newMarker -> { + if (!newTagNames.contains(newMarker.myTagName)) { + throw new IllegalArgumentException("Unexpected marker type: " + newMarker); + } + }))); + } + + /** + * Returns the only marker this object contains. + * If there are more than one marker, this method throws a runtime exception. + * If there is no marker, this method also throws a runtime exception. + * + * @return The only marker known by this instance. + */ + public @NotNull Marker single() { + return optional().orElseThrow(() -> new NoSuchElementException("No markers for " + myKnownTagNames)); + } + + /** + * Returns the only marker this object contains. + * If there is no marker, this method returns an empty optional. + * If there are more than one marker, this method throws a runtime exception. + * + * @return The only marker known by this instance. + */ + public @NotNull Optional optional() { + if (myMarkers.isEmpty()) { + return Optional.empty(); + } else if (myMarkers.size() > 1) { + throw new IllegalStateException("Multiple markers: " + myMarkers); + } else { + return Optional.of(myMarkers.get(0)); + } + } + + public int singleOffset(@NotNull TagName... tag) { + return markers(tag).single().offset(); + } + + public @NotNull TextRange singleRange(@NotNull TagName... tag) { + return markers(tag).single().range(); + } + + public @NotNull List ranges() { + return as(Marker::range); + } + + public @NotNull List ranges(@NotNull TagName... tag) { + return markers(tag).ranges(); + } + + public @NotNull List list() { + return as(Function.identity()); + } + + private List as(@NotNull Function mapper) { + return new AbstractList() { + @Override + public T get(int index) { + return mapper.apply(myMarkers.get(index)); + } + + @Override + public int size() { + return myMarkers.size(); + } + }; + } + + @Override + public @NotNull Iterator iterator() { + return myMarkers.iterator(); + } + + @Override + public int size() { + return myMarkers.size(); + } + + @Override + public @NotNull String toString() { + Deque> tokens = new ArrayDeque<>(); + for (Marker marker : myMarkers) { + if (marker.myStart == marker.myEnd) { + tokens.add(new Indexed<>(marker.myStart, new Tag(marker.myTagName, Tag.Type.SINGLE))); + } else { + tokens.addLast(new Indexed<>(marker.myStart, new Tag(marker.myTagName, Tag.Type.OPEN))); + tokens.addFirst(new Indexed<>(marker.myEnd, new Tag(marker.myTagName, Tag.Type.CLOSE))); + } + } + StringBuilder result = new StringBuilder(); + IntRef textOffset = new IntRef(); + tokens.stream().sorted(Comparator.comparingInt(Indexed::index)).forEach(indexed -> { + if (indexed.index() != textOffset.get()) { + assert indexed.index() > textOffset.get(); + result.append(myText, textOffset.get(), indexed.index()); + textOffset.set(indexed.index()); + } + result.append(indexed.item()); + }); + if (textOffset.get() != myText.length()) { + assert textOffset.get() < myText.length(); + result.append(myText, textOffset.get(), myText.length()); + } + return result.toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Markers markers = (Markers) o; + return Objects.equals(myText, markers.myText) && Objects.equals(myMarkers, markers.myMarkers); + } + + @Override + public int hashCode() { + return Objects.hash(myText, myMarkers); + } + + //endregion + //region Inner classes + + public static final class TagName { + + private static final @NotNull Pattern PATTERN_NAME = Pattern.compile("\\w+"); + + private final @NotNull String myName; + private final boolean myIsRange; + + private TagName(@NotNull String name, boolean isRange) { + if (!PATTERN_NAME.matcher(name).matches()) { + throw new IllegalArgumentException("Invalid tag name: " + name); + } + this.myName = name; + this.myIsRange = isRange; + } + + @Override + public String toString() { + return myName; + } + } + + public static final class Marker { + + private final @NotNull TagName myTagName; + private final int myStart; + private final int myEnd; + + private Marker(@NotNull TagName tagName, int start, int end) { + myTagName = tagName; + myStart = start; + myEnd = end; + } + + /** + * The {@link TagName} of this marker. + * + * @return tag name of this marker. + */ + public @NotNull TagName tagName() { + return myTagName; + } + + /** + * The offset where the marker starts in the unmarked text. + * + * @return Start offset of this marker. + */ + public int start() { + return myStart; + } + + /** + * The offset where the marker ends in the unmakred text. + * + * @return End offset of this marker. + */ + public int end() { + return myEnd; + } + + /** + * The {@linkplain #start() start offset} {@linkplain #end() end offset} as a {@link TextRange}. + * + * @return Location of this marker in unmarked text as {@link TextRange}. + */ + public @NotNull TextRange range() { + return TextRange.create(myStart, myEnd); + } + + /** + * Location of the marker in the unmarked text if the marker is empty. + * This method throws a runtime exception if {@link #start()} and {@link #end()} are different. + * This method is intended to be used for {@linkplain #tagNameVoid(String) void markers}. + * + * @return Location of this empty marker in unmarked text. + */ + public int offset() { + if (myStart != myEnd) throw new IllegalStateException("Cannot represent range as offset: " + this); + return myStart; + } + + @Override + public String toString() { + return "Marker{" + myTagName + ", start=" + myStart + ", end=" + myEnd + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Marker marker = (Marker) o; + return myStart == marker.myStart && myEnd == marker.myEnd && Objects.equals(myTagName, marker.myTagName); + } + + @Override + public int hashCode() { + return Objects.hash(myTagName, myStart, myEnd); + } + } + + private record Indexed(int index, @NotNull T item) {} + + private sealed interface Token { + int originalSize(); + } + + private record Text(@NotNull String string) implements Token { + @Override + public int originalSize() { + return string.length(); + } + + @Override + public String toString() { + return string; + } + } + + private record Tag(@NotNull TagName name, @NotNull Type type, int originalSize) implements Token { + private enum Type {SINGLE, OPEN, CLOSE} + + private Tag(@NotNull TagName name, @NotNull Type type) { + this(name, type, name.myName.length() + (type == Type.OPEN ? 2 : 3)); + } + + private @NotNull Type effectiveType() { + return !name.myIsRange && type == Type.OPEN ? Type.SINGLE : type; + } + + @Override + public String toString() { + return switch (type) { + case OPEN -> "<" + name + ">"; + case SINGLE -> "<" + name + "/>"; + case CLOSE -> ""; + }; + } + } + + //endregion +} diff --git a/src/test/java/org/nixos/idea/_testutil/MarkersTest.java b/src/test/java/org/nixos/idea/_testutil/MarkersTest.java new file mode 100644 index 00000000..7e45c82b --- /dev/null +++ b/src/test/java/org/nixos/idea/_testutil/MarkersTest.java @@ -0,0 +1,73 @@ +package org.nixos.idea._testutil; + +import com.intellij.openapi.util.TextRange; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class MarkersTest { + + private static final Markers.TagName TAG_1 = Markers.tagName("tag1"); + private static final Markers.TagName TAG_2 = Markers.tagName("tag2"); + private static final Markers.TagName TAG_3 = Markers.tagName("tag3"); + private static final Markers.TagName TAG_POS = Markers.tagNameVoid("pos"); + + @Test + void testParse() { + Markers markers = Markers.parse( + "abc def ghi ", + TAG_1, TAG_2, TAG_POS); + + assertEquals("abc def ghi ", markers.unmarkedText()); + assertEquals(List.of( + Markers.marker(TAG_1, 4, 11), + Markers.marker(TAG_POS, 9, 9), + Markers.marker(TAG_2, 10, 10), + Markers.marker(TAG_2, 16, 16) + ), markers.list()); + } + + @Test + void testFilter() { + Markers markers = Markers.parse( + "abc def ghi ", + TAG_1, TAG_2, TAG_POS); + + assertEquals(List.of( + Markers.marker(TAG_1, 4, 11) + ), markers.markers(TAG_1).list()); + assertEquals(List.of( + Markers.marker(TAG_2, 10, 10), + Markers.marker(TAG_2, 16, 16) + ), markers.markers(TAG_2).list()); + assertEquals(List.of( + Markers.marker(TAG_POS, 9, 9) + ), markers.markers(TAG_POS).list()); + } + + @Test + void testToString() { + Markers m = Markers.create("123456789", List.of( + Markers.marker(TAG_1, 0, 5), + Markers.marker(TAG_2, 0, 4), + Markers.marker(TAG_3, 0, 6) + ), TAG_1, TAG_2, TAG_3); + + assertEquals("123456789", m.toString()); + } + + @Test + void testEqualsIgnoresOrder() { + Markers m1 = Markers.create("0123456789", TAG_1, List.of( + TextRange.create(0, 5), + TextRange.create(0, 6) + )); + Markers m2 = Markers.create("0123456789", TAG_1, List.of( + TextRange.create(0, 6), + TextRange.create(0, 5) + )); + assertEquals(m1, m2); + } +} diff --git a/src/test/java/org/nixos/idea/_testutil/TestFactoryDsl.kt b/src/test/java/org/nixos/idea/_testutil/TestFactoryDsl.kt new file mode 100644 index 00000000..d3e772f9 --- /dev/null +++ b/src/test/java/org/nixos/idea/_testutil/TestFactoryDsl.kt @@ -0,0 +1,65 @@ +package org.nixos.idea._testutil + +import kotlinx.collections.immutable.toImmutableList +import org.junit.jupiter.api.DynamicContainer.dynamicContainer +import org.junit.jupiter.api.DynamicNode +import org.junit.jupiter.api.DynamicTest.dynamicTest +import org.junit.jupiter.api.Named + +@DslMarker +private annotation class TestFactoryDslMarker + +@TestFactoryDslMarker +class TestFactoryDsl private constructor() { + + private val nodes = mutableListOf() + + companion object { + fun testFactory(init: TestFactoryDsl.() -> Unit): List { + val container = TestFactoryDsl() + container.init() + return container.nodes.toImmutableList() + } + } + + fun test(name: String, test: Test.() -> Unit) { + nodes += dynamicTest(name, { test.invoke(Test()) }) + } + + fun container(name: String, init: TestFactoryDsl.() -> Unit) { + val container = TestFactoryDsl() + container.init() + if (container.nodes.isNotEmpty()) { + nodes += dynamicContainer(name, container.nodes.toImmutableList()) + } + } + + fun tests(name: String, data: Iterable, test: Test.(T) -> Unit) { + val list = data.toImmutableList() + when (list.size) { + 0 -> {} + 1 -> nodes += dynamicTest(name, { Test().test(list[0]) }) + else -> nodes += dynamicContainer(name, list.map { dynamicTest(it.toString(), { Test().test(it) }) }) + } + } + + fun containers(name: String, data: Iterable, init: TestFactoryDsl.(T) -> Unit) { + val list = data.flatMap { + val container = TestFactoryDsl() + container.init(it) + if (container.nodes.isEmpty()) { + emptyList() + } else { + listOf(Named.of(it.toString(), container.nodes.toImmutableList())) + } + }.toImmutableList() + when (list.size) { + 0 -> {} + 1 -> nodes += dynamicContainer(name, list[0].payload) + else -> nodes += dynamicContainer(name, list.map { dynamicContainer(it.name, it.payload) }) + } + } + + @TestFactoryDslMarker + class Test +} diff --git a/src/test/java/org/nixos/idea/_testutil/WithIdeaPlatform.java b/src/test/java/org/nixos/idea/_testutil/WithIdeaPlatform.java index fe33c089..4f3a25d8 100644 --- a/src/test/java/org/nixos/idea/_testutil/WithIdeaPlatform.java +++ b/src/test/java/org/nixos/idea/_testutil/WithIdeaPlatform.java @@ -8,11 +8,17 @@ import com.intellij.openapi.project.Project; import com.intellij.testFramework.EdtTestUtil; import com.intellij.testFramework.fixtures.BasePlatformTestCase; +import com.intellij.testFramework.fixtures.CodeInsightTestFixture; import com.intellij.testFramework.fixtures.IdeaProjectTestFixture; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.parallel.ResourceLock; +import org.nixos.idea.NixTestExecutionPolicy; -import java.lang.annotation.*; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; /** * {@code @WithIdeaPlatform} can be used to set up an IDEA platform test environment. @@ -56,4 +62,22 @@ @ExtendWith(EdtExtension.class) @WithIdeaPlatform @interface OnEdt {} + + /** + * Enables the use of {@link CodeInsightTestFixture}. + * This annotation inherits {@link WithIdeaPlatform}, so you don't have to add it separately. + */ + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD, ElementType.TYPE}) + @Inherited + @WithIdeaPlatform + @interface CodeInsight { + /** + * Specifies the path to the test data relative to {@link NixTestExecutionPolicy#getHomePath()}. + * This is equivalent to the {@code getBasePath()} method in {@link BasePlatformTestCase}. + * + * @return Relative path to the test data used by this test. + */ + String basePath() default ""; + } } diff --git a/src/test/java/org/nixos/idea/lang/references/AbstractSymbolNavigationTests.kt b/src/test/java/org/nixos/idea/lang/references/AbstractSymbolNavigationTests.kt new file mode 100644 index 00000000..db342072 --- /dev/null +++ b/src/test/java/org/nixos/idea/lang/references/AbstractSymbolNavigationTests.kt @@ -0,0 +1,249 @@ +package org.nixos.idea.lang.references + +import com.intellij.find.usages.api.UsageAccess +import com.intellij.psi.PsiFile +import com.intellij.psi.util.PsiTreeUtil +import com.intellij.testFramework.PsiTestUtil +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import org.intellij.lang.annotations.Language +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DynamicNode +import org.nixos.idea._testutil.Markers +import org.nixos.idea._testutil.TestFactoryDsl +import org.nixos.idea._testutil.WithIdeaPlatform +import org.nixos.idea.file.NixFileType +import org.nixos.idea.lang.builtins.NixBuiltin +import org.nixos.idea.lang.references.symbol.NixSymbol +import org.nixos.idea.psi.NixDeclarationHost +import org.nixos.idea.settings.NixSymbolSettings + +@WithIdeaPlatform.OnEdt +@WithIdeaPlatform.CodeInsight +@Suppress("UnstableApiUsage") +abstract class AbstractSymbolNavigationTests { + + private lateinit var myFixture: CodeInsightTestFixture + private lateinit var mySymbolHelper: SymbolTestHelper + + @BeforeEach + fun setUp(fixture: CodeInsightTestFixture) { + myFixture = fixture + mySymbolHelper = SymbolTestHelper(fixture) + } + + class Config { + @Language("HTML") + lateinit var code: String + + var findDeclarations = true + var findReferences = true + var resolveDeclarations = true + var resolveReferences = true + } + + /** + * Test factory for symbol navigation. + * + * ``` + * @TestFactory + * fun simple_assignment() = test { + * code = """ + * let x.y = "..."; in + * x.y + * """.trimIndent() + * } + * ``` + * + * The code may contain the following tags: + * + * * **`...`** + * The name of a [NixBuiltin] which is used by the test. + * The location of this tag doesn't have any impact, only the content. + * By convention, it is recommended to but this tag into a comment at the top of the file. + * * **`...`** + * The attribute path of a [NixUserSymbol] which is used by the test. + * The tag must be located within the corresponding [NixDeclarationHost]. + * The exact location within the declaration host doesn't have any impact. + * There must be at most one symbol per declaration host. + * * **`...`** + * The identifiers which declare the symbol. + * The test factory verifies that each marked identifier is a declaration + * which resolves to the symbol within the same declaration host. + * * **`...`** + * The identifiers which reference the symbols. + * The test factory verifies that each marked identifier is a reference + * which resolves to all the marked symbols and builtins. + * + * Based on the given tags, the method will generate tests for the following scenarios: + * + * * **Go to Declaration:** + * For each symbol, try to resolve all declarations in the same declaration host. + * * **Find Usages:** + * For each symbol, try to find all usages (declarations and references). + * * **Resolve Declaration:** + * For each declaration, try to resolve the matching symbol. + * * **Resolve References:** + * For each reference, try to resolve all symbols. + */ + fun test(init: Config.() -> Unit): List { + val config = Config() + config.init() + + val markers = Markers.parse(config.code, TAG_BUILTIN, TAG_SYMBOL, TAG_DECL, TAG_REF) + val unmarkedCode = markers.unmarkedText() + val symbolMarkers = markers.markers(TAG_BUILTIN, TAG_SYMBOL) + val declarationMarkers = markers.markers(TAG_DECL) + val referenceMarkers = markers.markers(TAG_REF) + + NixSymbolSettings.getInstance().enabled = true + val file = myFixture.configureByText(NixFileType.INSTANCE, unmarkedCode) + PsiTestUtil.checkErrorElements(file) // Fail early if there is a syntax error + + return TestFactoryDsl.testFactory { + if (config.findDeclarations) { + containers("go to declaration", symbolMarkers) { symbolMarker -> + test("jump to first declaration = true") { + testGoToDeclaration(file, symbolMarker, declarationMarkers, true) + } + test("jump to first declaration = false") { + testGoToDeclaration(file, symbolMarker, declarationMarkers, false) + } + } + } + if (config.findReferences) { + containers("find usages", symbolMarkers) { symbolMarker -> + test("show declarations as usages = false") { + NixSymbolSettings.getInstance().showDeclarationsAsUsages = false + testFindUsages(file, symbolMarker, referenceMarkers, false) + } + test("show declarations as usages = true") { + NixSymbolSettings.getInstance().showDeclarationsAsUsages = true + if (config.findDeclarations) { + val withDeclarations = referenceMarkers.withMarkers( + declarationMarkers.filterSameHostAs( + file, + symbolMarker.start() + ).stream(), + TAG_DECL + ) + testFindUsages(file, symbolMarker, withDeclarations, false) + } else { + testFindUsages(file, symbolMarker, referenceMarkers, true) + } + } + } + } + if (config.resolveDeclarations) { + tests("resolve declaration", declarationMarkers) { + val offset = it.start() + val symbols = getSymbols(file, symbolMarkers.filterSameHostAs(file, offset)).toSet() + val declaration = mySymbolHelper.findDeclaration(NixSymbolDeclaration::class.java, file, offset) + Assertions.assertEquals(it.range(), declaration.absoluteRange) + Assertions.assertTrue(declaration.symbol in symbols) + } + } + if (config.resolveReferences) { + tests("resolve reference", referenceMarkers) { + val symbols = getSymbols(file, symbolMarkers).toSet() + val reference = mySymbolHelper.findReference(NixSymbolReference::class.java, file, it.start()) + Assertions.assertEquals(it.range(), reference.element.textRange.cutOut(reference.rangeInElement)) + Assertions.assertEquals(symbols, reference.resolveReference().toSet()) + } + } + } + } + + private fun getSymbols(file: PsiFile, symbolMarkers: Iterable): Iterable { + return symbolMarkers.map { getSymbol(file, it) } + } + + private fun getSymbol(file: PsiFile, marker: Markers.Marker): NixSymbol { + return when (marker.tagName()) { + TAG_BUILTIN -> { + val name = marker.range().substring(file.text) + val instance = NixBuiltin.resolveBuiltin(name) + NixSymbol.builtin(requireNotNull(instance)) + } + + TAG_SYMBOL -> { + val attrPath = marker.range().substring(file.text) + mySymbolHelper.findSymbol(file, attrPath, marker.start()) + } + + else -> throw IllegalStateException("Unknown tag name: " + marker.tagName()) + } + } + + private fun Iterable.filterSameHostAs(file: PsiFile, offset: Int): List { + val needle = PsiTreeUtil.findElementOfClassAtOffset(file, offset, NixDeclarationHost::class.java, false) + return this.filter { + val host = PsiTreeUtil.findElementOfClassAtOffset(file, it.start(), NixDeclarationHost::class.java, false) + host == needle + } + } + + private fun testGoToDeclaration( + file: PsiFile, + symbolMarker: Markers.Marker, + declarationMarkers: Markers, + jumpToFirstDeclaration: Boolean + ) { + NixSymbolSettings.getInstance().jumpToFirstDeclaration = jumpToFirstDeclaration + val symbol = getSymbol(file, symbolMarker) + val navigationTargets = mySymbolHelper.findNavigationTargets(NixNavigationTarget::class.java, symbol) + var expected = declarationMarkers + .filterSameHostAs(file, symbolMarker.start()) + .map { it.range() } + if (jumpToFirstDeclaration) { + expected = expected.stream().limit(1).toList() + } + Assertions.assertEquals( + Markers.create( + declarationMarkers.unmarkedText(), + TAG_DECL, + expected + ), + Markers.create( + declarationMarkers.unmarkedText(), + TAG_DECL, + navigationTargets.stream().map { it.rangeInFile }.toList() + ) + ) + } + + private fun testFindUsages( + file: PsiFile, + symbolMarker: Markers.Marker, + usageMarkers: Markers, + ignoreDeclarations: Boolean + ) { + val symbol = getSymbol(file, symbolMarker) + Assertions.assertEquals( + usageMarkers, + Markers.create( + usageMarkers.unmarkedText(), + mySymbolHelper.findUsages(symbol, file) + .filter { !it.declaration } + .flatMap { + val nixUsage = it as NixUsage + Assertions.assertEquals(file, nixUsage.file) + val isDeclaration = nixUsage.computeAccess() == UsageAccess.Write + when { + isDeclaration && ignoreDeclarations -> emptyList() + isDeclaration -> listOf(Markers.marker(TAG_DECL, nixUsage.range)) + else -> listOf(Markers.marker(TAG_REF, nixUsage.range)) + } + }, + TAG_DECL, TAG_REF + ) + ) + } + + companion object { + private val TAG_BUILTIN: Markers.TagName = Markers.tagName("builtin") + private val TAG_SYMBOL: Markers.TagName = Markers.tagName("symbol") + private val TAG_REF: Markers.TagName = Markers.tagName("ref") + private val TAG_DECL: Markers.TagName = Markers.tagName("decl") + } +} diff --git a/src/test/java/org/nixos/idea/lang/references/SymbolNavigationTest.kt b/src/test/java/org/nixos/idea/lang/references/SymbolNavigationTest.kt new file mode 100644 index 00000000..c94a4505 --- /dev/null +++ b/src/test/java/org/nixos/idea/lang/references/SymbolNavigationTest.kt @@ -0,0 +1,315 @@ +package org.nixos.idea.lang.references + +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.TestFactory + +class SymbolNavigationTest : AbstractSymbolNavigationTests() { + + @Nested + inner class RecursiveSet { + + @TestFactory + fun simple_assignment() = test { + code = """ + rec { + subject = "..."; + dummy = "..."; + body = subject; + } + """.trimIndent() + } + + @TestFactory + fun child_assignment() = test { + code = """ + rec { + subject.subject = "..."; + dummy.subject = "..."; + body = subject; + } + """.trimIndent() + } + + @TestFactory + fun multiple_assignments() = test { + code = """ + rec { # subject + subject.child = "..."; + dummy.subject = "..."; + body = subject; + subject = "..."; + dummy.subject = "..."; + subject.child = "..."; + } + """.trimIndent() + } + + @TestFactory + fun conflicting_declarations() = test { + code = """ + rec { # subject + subject = "..."; + inherit (unknown-value) subject; + body = subject; + } + """.trimIndent() + } + + @TestFactory + fun simple_inherit() = test { + code = """ + rec { + inherit (unknown-value) + subject + dummy; + body = subject; + } + """.trimIndent() + } + + @TestFactory + fun recursive() = test { + code = """ + rec { subject = subject; } + """.trimIndent() + } + } + + @Nested + inner class LetExpression { + + @TestFactory + fun simple_assignment() = test { + code = """ + let + dummy = "..."; + subject = "..."; + in + subject + """.trimIndent() + } + + @TestFactory + fun child_assignment() = test { + code = """ + let + dummy.subject = "..."; + subject.subject = "..."; + in + subject + """.trimIndent() + } + + @TestFactory + fun multiple_assignments() = test { + code = """ + let # subject + subject.child = "..."; + dummy.subject = "..."; + subject = "..."; + dummy.subject = "..."; + subject.child = "..."; + in + subject + """.trimIndent() + } + + @TestFactory + fun conflicting_declarations() = test { + code = """ + let # subject + inherit (unknown-value) subject; + subject = "..."; + in + subject + """.trimIndent() + } + + @TestFactory + fun simple_inherit() = test { + code = """ + let + inherit (unknown-value) + dummy + subject; + in + subject + """.trimIndent() + } + + @TestFactory + fun recursive() = test { + code = """ + let subject = subject; + in subject + """.trimIndent() + } + } + + @Nested + inner class Parameter { + + @TestFactory + fun simple_parameter() = test { + code = """ + subject: dummy: + subject + """.trimIndent() + } + + @TestFactory + fun formal_parameter() = test { + code = """ + { subject ? "...", dummy }: + subject + """.trimIndent() + } + + @TestFactory + fun conflicting_parameters() = test { + code = """ + subject @ { + subject ? "...", + dummy, + subject, + dummy, + subject ? "...", + }: + subject + """.trimIndent() + } + } + + @Nested + inner class InheritStatement { + + @TestFactory + fun as_reference() = test { + code = """ + let subject = "..."; in + { inherit dummy subject; } + """.trimIndent() + } + + @TestFactory + fun recursive_shadow_trap() = test { + // When inheriting variables from the lexical scope, + // the inherit statement must not shadow the inherited variable. + code = """ + let subject = "..."; in + let inherit dummy subject; in + rec { + inherit dummy subject; + body = subject; + } + """.trimIndent() + } + } + + @Nested + inner class Builtins { + + @TestFactory + @DisplayName("builtins") + fun builtins() = test { + code = """ + # builtins + [ builtins builtins.null dummy ] + """.trimIndent() + } + + @TestFactory + @DisplayName("null") + fun null_direct() = test { + code = """ + # null + null + """.trimIndent() + } + } + + @Nested + inner class Shadowing { + + @TestFactory + fun shadowed_by_assignment() = test { + code = """ + let subject = "..."; in + let inherit (unknown-value) subject; in + let subject = "..."; in + subject + """.trimIndent() + } + + @TestFactory + fun shadowed_by_inherit() = test { + code = """ + let subject = "..."; in + let inherit (unknown-value) subject; in + let inherit (unknown-value) subject; in + subject + """.trimIndent() + } + + @TestFactory + fun shadowed_by_recursive_set() = test { + code = """ + let subject = "..."; in + let inherit (unknown-value) subject; in + rec { subject = subject; } + """.trimIndent() + } + + @TestFactory + fun shadowed_builtin() = test { + code = """ + let builtins = "..."; in + builtins.abc + """.trimIndent() + } + } + + @Nested + inner class StringNotation { + + @TestFactory + fun declaration() = test { + code = """ + let "subject" = "..."; in + subject + """.trimIndent() + } + + @TestFactory + fun with_special_character() = test { + code = """ + let "my very special variable ⇐" = "..."; in + { inherit "my very special variable ⇐"; } + """.trimIndent() + } + + @TestFactory + // TODO: Maybe use some custom index in NixUsageSearcher? + @Disabled("NixUsageSearcher uses text search, so it only finds usages where the text matches the symbol") + fun with_escape_sequence() = test { + // symbol name: $$$ + code = """ + let "${"$\\$$"}" = "..."; in + { inherit "${"$\\$$"}"; } + """.trimIndent() + } + + @TestFactory + // TODO: Maybe use some custom index in NixUsageSearcher? + @Disabled("NixUsageSearcher uses text search, so it only finds usages where the text matches the symbol") + fun with_escape_sequence_non_normalized() = test { + // symbol name: $$$ + code = """ + let "${"$\\$$"}" = "..."; in + { inherit "${"$$\\$"}"; } + """.trimIndent() + } + } +} diff --git a/src/test/java/org/nixos/idea/lang/references/SymbolTestHelper.java b/src/test/java/org/nixos/idea/lang/references/SymbolTestHelper.java new file mode 100644 index 00000000..9fe9e48a --- /dev/null +++ b/src/test/java/org/nixos/idea/lang/references/SymbolTestHelper.java @@ -0,0 +1,150 @@ +package org.nixos.idea.lang.references; + +import com.intellij.find.usages.api.SearchTarget; +import com.intellij.find.usages.api.Usage; +import com.intellij.find.usages.api.UsageSearchParameters; +import com.intellij.model.Symbol; +import com.intellij.model.psi.PsiSymbolDeclaration; +import com.intellij.model.psi.PsiSymbolReference; +import com.intellij.model.search.SearchService; +import com.intellij.navigation.SymbolNavigationService; +import com.intellij.openapi.application.ReadAction; +import com.intellij.openapi.project.Project; +import com.intellij.platform.backend.navigation.NavigationTarget; +import com.intellij.psi.PsiFile; +import com.intellij.psi.search.LocalSearchScope; +import com.intellij.psi.search.SearchScope; +import com.intellij.psi.util.PsiTreeUtil; +import com.intellij.psi.util.PsiTreeUtilKt; +import com.intellij.testFramework.fixtures.CodeInsightTestFixture; +import org.jetbrains.annotations.NotNull; +import org.nixos.idea.lang.references.symbol.NixUserSymbol; +import org.nixos.idea.psi.NixAttrPath; +import org.nixos.idea.psi.NixDeclarationHost; +import org.nixos.idea.psi.NixElementFactory; +import org.nixos.idea.psi.NixPsiUtil; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.fail; + +public final class SymbolTestHelper { + + private final @NotNull CodeInsightTestFixture myFixture; + + public SymbolTestHelper(@NotNull CodeInsightTestFixture fixture) { + myFixture = fixture; + } + + //region findSymbol + + public @NotNull NixUserSymbol findSymbol(@NotNull PsiFile file, @NotNull String attrPath, int offset) { + NixAttrPath pathElement = NixElementFactory.createAttrPath(file.getProject(), attrPath); + List path = pathElement.getAttrList().stream().map(NixPsiUtil::getAttributeName).toList(); + return findSymbol(file, path, offset); + } + + public @NotNull NixUserSymbol findSymbol(@NotNull PsiFile file, @NotNull List path, int offset) { + NixDeclarationHost host = PsiTreeUtil.findElementOfClassAtOffset(file, offset, NixDeclarationHost.class, false); + if (host == null) { + throw new IllegalStateException("No NixDeclarationHost found on given location"); + } + NixUserSymbol symbol = host.getSymbol(path); + return Objects.requireNonNull(symbol, "Symbol not found: " + String.join(".", path)); + } + + //endregion + //region findDeclarations + + @SuppressWarnings("UnstableApiUsage") + public @NotNull T findDeclaration(@NotNull Class type, @NotNull PsiFile file, int offset) { + Collection declarations = findDeclarations(file, offset); + List typedDeclarations = declarations.stream() + .filter(type::isInstance).map(type::cast) + .toList(); + if (declarations.isEmpty()) { + return fail("No declaration found"); + } else if (typedDeclarations.isEmpty()) { + return fail(String.format("No declaration of type %s found. Found: %s", type.getSimpleName(), declarations)); + } else if (typedDeclarations.size() > 1) { + return fail("Multiple declarations found: " + declarations); + } else { + return typedDeclarations.get(0); + } + } + + @SuppressWarnings("UnstableApiUsage") + public @NotNull Collection findDeclarations(@NotNull PsiFile file, int offset) { + return findDeclarations(PsiSymbolDeclaration.class, file, offset); + } + + @SuppressWarnings({"UnstableApiUsage", "OverrideOnly"}) + public @NotNull Collection findDeclarations(@NotNull Class type, @NotNull PsiFile file, int offset) { + List allDeclarations = new ArrayList<>(); + PsiTreeUtilKt.elementsAtOffsetUp(file, offset) + .forEachRemaining(element -> allDeclarations.addAll(element.getFirst().getOwnDeclarations())); + return allDeclarations.stream() + .filter(declaration -> declaration.getAbsoluteRange().contains(offset)) + .filter(type::isInstance).map(type::cast) + .toList(); + } + + //endregion + //region findReferences + + @SuppressWarnings("UnstableApiUsage") + public @NotNull T findReference(@NotNull Class type, @NotNull PsiFile file, int offset) { + myFixture.openFileInEditor(file.getVirtualFile()); + myFixture.getEditor().getCaretModel().moveToOffset(offset); + return assertInstanceOf(type, ReadAction.compute(myFixture::findSingleReferenceAtCaret)); + } + + //endregion + //region findNavigationTargets + + @SuppressWarnings("UnstableApiUsage") + public @NotNull Collection findNavigationTargets(@NotNull Class type, @NotNull Symbol symbol) { + return SymbolNavigationService.getInstance().getNavigationTargets(myFixture.getProject(), symbol).stream() + .filter(type::isInstance).map(type::cast) + .toList(); + } + + //endregion + //region findUsages + + @SuppressWarnings("UnstableApiUsage") + public @NotNull Collection findUsages(@NotNull SearchTarget target, @NotNull PsiFile file) { + return findUsages(target, new LocalSearchScope(file)); + } + + @SuppressWarnings("UnstableApiUsage") + public @NotNull Collection findUsages(@NotNull SearchTarget target, @NotNull SearchScope scope) { + return SearchService.getInstance().searchParameters(new UsageSearchParameters() { + @Override + public @NotNull SearchTarget getTarget() { + return target; + } + + @Override + public @NotNull SearchScope getSearchScope() { + return scope; + } + + @Override + public @NotNull Project getProject() { + return myFixture.getProject(); + } + + @Override + public boolean areValid() { + return true; + } + }).findAll(); + } + + //endregion +}