Phase 5: add rename and find usages
This commit is contained in:
35
AGENTS.md
35
AGENTS.md
@@ -59,20 +59,23 @@ class and still prints `OK`. `jaitest` therefore passes
|
||||
Last verified state (all green, `./jaigradle check` and `verifyPlugin` too):
|
||||
|
||||
```text
|
||||
dev.hgh.HarnessSmokeTest tests=2
|
||||
dev.hgh.jai.JaiFileTypeTest tests=4
|
||||
dev.hgh.jai.editor.JaiEditorSupportTest tests=7
|
||||
dev.hgh.jai.editor.JaiFoldingBuilderTest tests=3
|
||||
dev.hgh.jai.highlighting.* tests=8
|
||||
dev.hgh.jai.lexer.JaiCorpusLexerTest tests=2 <- the Tier 0 gate
|
||||
dev.hgh.jai.lexer.JaiLexerTest tests=13
|
||||
dev.hgh.jai.parser.JaiCorpusParserTest tests=1 <- the Tier 3 gate
|
||||
dev.hgh.jai.parser.JaiParserGoldenTest tests=5 <- Tier 2 golden trees
|
||||
dev.hgh.jai.parser.JaiParserLongTailTest tests=1 <- focused parser regressions
|
||||
dev.hgh.jai.parser.DebugParseTest tests=2 <- scratch harness, inert
|
||||
dev.hgh.jai.reference.JaiReferenceTest tests=4
|
||||
dev.hgh.jai.structure.JaiStructureViewTest tests=3
|
||||
-> total 55, failures+errors 0
|
||||
dev.hgh.HarnessSmokeTest tests=2
|
||||
dev.hgh.jai.JaiFileTypeTest tests=4
|
||||
dev.hgh.jai.completion.JaiCompletionTest tests=6
|
||||
dev.hgh.jai.editor.JaiEditorSupportTest tests=7
|
||||
dev.hgh.jai.editor.JaiFoldingBuilderTest tests=3
|
||||
dev.hgh.jai.findusages.JaiFindUsagesTest tests=2
|
||||
dev.hgh.jai.highlighting.* tests=8
|
||||
dev.hgh.jai.lexer.JaiCorpusLexerTest tests=2 <- the Tier 0 gate
|
||||
dev.hgh.jai.lexer.JaiLexerTest tests=13
|
||||
dev.hgh.jai.parser.JaiCorpusParserTest tests=1 <- the Tier 3 gate
|
||||
dev.hgh.jai.parser.JaiParserGoldenTest tests=5 <- Tier 2 golden trees
|
||||
dev.hgh.jai.parser.JaiParserLongTailTest tests=1 <- focused parser regressions
|
||||
dev.hgh.jai.parser.DebugParseTest tests=2 <- scratch harness, inert
|
||||
dev.hgh.jai.refactoring.JaiRenameTest tests=3
|
||||
dev.hgh.jai.reference.JaiReferenceTest tests=9
|
||||
dev.hgh.jai.structure.JaiStructureViewTest tests=3
|
||||
-> total 71, failures+errors 0
|
||||
```
|
||||
|
||||
The corpus gates report what they actually did; check both lines are still there:
|
||||
@@ -180,7 +183,9 @@ Tier 3: parsed 714 files, 714 clean (100.0%), 0 with errors, 0 PsiErrorElements
|
||||
compiler directives, `#import`/`#load` module paths is implemented in
|
||||
`dev.hgh.jai.completion.JaiCompletionContributor`. Identifier references now
|
||||
resolve procedures/types in the same file and transitively loaded/imported
|
||||
files, with headless fixture coverage. Rename and find-usages remain.
|
||||
files. Declaration names now support rename and find-usages through the
|
||||
standard PSI contracts; module-qualified members, parameters, and fields
|
||||
remain future symbol-resolution work.
|
||||
2. **Phases 6+** — formatter, inspections, compiler integration, and other
|
||||
optional work remain. `#asm` bodies are intentionally consumed opaquely, so
|
||||
nothing inside them has PSI yet.
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
- Folding for blocks, nested comments, and multi-line here-strings.
|
||||
- Go-to-definition references for `#import` and `#load`, including project-relative
|
||||
files and the local Jai module layout (`Name.jai` or `Name/module.jai`).
|
||||
- Basic completion for Jai keywords, compiler directives, and module/file paths.
|
||||
- Go-to-definition for procedure and type symbols in the same file and through
|
||||
loaded or imported Jai files.
|
||||
- Rename and find-usages support for declaration names and resolved references.
|
||||
|
||||
### Known gaps
|
||||
|
||||
@@ -29,3 +33,5 @@
|
||||
inner PSI or structure entries.
|
||||
- Import resolution uses the project/module roots and the local Jai installation;
|
||||
custom compiler `-import_dir` arguments are not discovered yet.
|
||||
- Module-qualified members, procedure parameters, and struct fields do not yet
|
||||
have symbol resolution.
|
||||
|
||||
11
README.md
11
README.md
@@ -11,13 +11,20 @@ never from online material (see `AGENTS.md`).
|
||||
(`#import`, `#run`, …), notes (`@Cleanup`), numbers (hex / binary / hexfloat /
|
||||
`_` separators), strings, here-strings (`#string DONE … DONE`), nesting block
|
||||
comments, operators, `---`, `$`/`$$`, `it` / `it_index`
|
||||
- Grammar-Kit parser and PSI for the full local 714-file corpus
|
||||
- Structure view and folding for blocks, comments, and here-strings
|
||||
- Colour settings page (**Settings → Editor → Color Scheme → Jai**)
|
||||
- Comment / uncomment (`//` and nesting `/* */`)
|
||||
- Brace matching, including the Jai-only `.{` struct-literal and `.[`
|
||||
array-literal openers
|
||||
- Basic completion for keywords, directives, and local Jai module/file paths
|
||||
- Go-to-definition for procedures and types in the same file and through
|
||||
`#import`/`#load`
|
||||
- Rename and find-usages for declaration names and their resolved references
|
||||
|
||||
Not yet: parser and PSI, so no structure view, go-to-definition, completion,
|
||||
formatting, or inspections. See `docs/BUILD_PLAN.md` for the phase plan.
|
||||
Remaining code-insight gaps include module-qualified members, parameters, and
|
||||
struct fields. Formatting, inspections, and compiler integration are planned
|
||||
later. See `docs/BUILD_PLAN.md` for the phase plan.
|
||||
|
||||
## Install it locally
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@ Each phase has a machine-checkable gate. Do not advance without a green gate.
|
||||
| **2** | `SyntaxHighlighter`, color settings page, commenter, brace matcher | Tier 1 golden dumps; highlighter maps every token type |
|
||||
| **3** | `.bnf` grammar, generated parser + PSI, `ParserDefinition` | Tier 2 golden trees; Tier 3 corpus parse ≥ target — **done, 100.0% (714/714)** |
|
||||
| **4** | Structure view, folding, `#import`/`#load` reference resolution + go-to-definition | Tier 4 fixture tests — **done** |
|
||||
| **5** | Completion (keywords, directives, module names), rename, find-usages | Tier 4 fixture tests |
|
||||
| **5** | Completion (keywords, directives, module names), rename, find-usages | Tier 4 fixture tests — **done** (70-test suite) |
|
||||
| **6** | Formatter, code style settings | Formatter round-trip: formatting the corpus is idempotent |
|
||||
| **7** | Inspections (e.g. `#must` misuse), quick fixes, live templates | Tier 4 + `verifyPlugin` |
|
||||
| **8** | Optional: run-configuration to invoke the `jai` compiler, parse its error output | Integration test against `~/.local/jai/bin` |
|
||||
@@ -196,8 +196,9 @@ corpus-scale invariant that needs no human judgment.
|
||||
|
||||
## 6. Open questions for you
|
||||
|
||||
1. **Scope/ambition** — stop at solid syntax highlighting + navigation (Phases
|
||||
0–4), or go all the way to formatter and inspections (0–7)?
|
||||
1. **Scope/ambition** — stop at the current syntax, navigation, and basic
|
||||
refactoring support (Phases 0–5), or go all the way to formatter and
|
||||
inspections (0–7)?
|
||||
2. **Compiler integration** (Phase 8) — worth it? It's the only phase needing
|
||||
the actual `jai` binary, and it's the least testable.
|
||||
3. **Target IDE** — IntelliJ IDEA only (current setting), or all JetBrains IDEs?
|
||||
@@ -206,5 +207,6 @@ corpus-scale invariant that needs no human judgment.
|
||||
setup, works today), or install a system JDK via Homebrew? I'd default to the
|
||||
wrapper.
|
||||
|
||||
My recommendation: approve Phases 0–4 now, decide on 5–8 once the parser gate is
|
||||
green and we can see how the grammar behaved against real code.
|
||||
Phases 0–5 are now implemented and verified headlessly. The remaining decision
|
||||
is whether to continue with the optional formatter, inspections, and compiler
|
||||
integration work in Phases 6–8.
|
||||
|
||||
@@ -8,12 +8,12 @@ import com.intellij.psi.PsiElement;
|
||||
import com.intellij.psi.PsiElementVisitor;
|
||||
import com.intellij.psi.util.PsiTreeUtil;
|
||||
import static dev.hgh.jai.psi.JaiTypes.*;
|
||||
import com.intellij.extapi.psi.ASTWrapperPsiElement;
|
||||
import dev.hgh.jai.psi.mixin.JaiNamedElementMixin;
|
||||
import dev.hgh.jai.psi.*;
|
||||
|
||||
public class JaiDeclNameImpl extends ASTWrapperPsiElement implements JaiDeclName {
|
||||
public class JaiDeclNameImpl extends JaiNamedElementMixin implements JaiDeclName {
|
||||
|
||||
public JaiDeclNameImpl(@NotNull ASTNode node) {
|
||||
public JaiDeclNameImpl(ASTNode node) {
|
||||
super(node);
|
||||
}
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ private declModifier_ ::= <<dirWithBlock>> directiveFlag_* block
|
||||
private usingModifier_ ::= 'using' [directiveFlag_ [<<exprNoAssign>>]]
|
||||
declNames ::= declName (['='] ',' declName ['='])* ['=']
|
||||
declName ::= ['`'] (IDENT | keywordAsName_)
|
||||
{ mixin="dev.hgh.jai.psi.mixin.JaiNamedElementMixin" }
|
||||
private declTail_ ::= ':' [<<exprNoAssign>>] procModifier* [',' assignTargets_] [('=' | ':') initializerList_]
|
||||
| ':=' initializerList_
|
||||
| '::' initializerList_
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package dev.hgh.jai.findusages
|
||||
|
||||
import com.intellij.lang.cacheBuilder.DefaultWordsScanner
|
||||
import com.intellij.lang.cacheBuilder.WordsScanner
|
||||
import com.intellij.lang.findUsages.FindUsagesProvider
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiNameIdentifierOwner
|
||||
import com.intellij.psi.PsiNamedElement
|
||||
import com.intellij.psi.tree.TokenSet
|
||||
import dev.hgh.jai.lexer.JaiLexer
|
||||
import dev.hgh.jai.lexer.JaiTokenTypes
|
||||
import dev.hgh.jai.psi.JaiRefExpr
|
||||
|
||||
/** Supplies word indexing and descriptions for Jai declaration usages. */
|
||||
class JaiFindUsagesProvider : FindUsagesProvider {
|
||||
override fun getWordsScanner(): WordsScanner =
|
||||
DefaultWordsScanner(
|
||||
JaiLexer(),
|
||||
TokenSet.create(JaiTokenTypes.IDENT),
|
||||
JaiTokenTypes.COMMENTS,
|
||||
JaiTokenTypes.STRINGS,
|
||||
)
|
||||
|
||||
override fun canFindUsagesFor(psiElement: PsiElement): Boolean = psiElement is PsiNameIdentifierOwner || psiElement is JaiRefExpr
|
||||
|
||||
override fun getHelpId(psiElement: PsiElement): String? = null
|
||||
|
||||
override fun getType(element: PsiElement): String =
|
||||
if (element is PsiNameIdentifierOwner || element is JaiRefExpr) {
|
||||
"Jai declaration"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
override fun getDescriptiveName(element: PsiElement): String = (element as? PsiNamedElement)?.name ?: element.text
|
||||
|
||||
override fun getNodeText(
|
||||
element: PsiElement,
|
||||
useFullName: Boolean,
|
||||
): String = element.text
|
||||
}
|
||||
23
src/main/kotlin/dev/hgh/jai/psi/JaiPsiElementFactory.kt
Normal file
23
src/main/kotlin/dev/hgh/jai/psi/JaiPsiElementFactory.kt
Normal file
@@ -0,0 +1,23 @@
|
||||
package dev.hgh.jai.psi
|
||||
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFileFactory
|
||||
import com.intellij.psi.util.PsiTreeUtil
|
||||
import dev.hgh.jai.JaiLanguage
|
||||
import dev.hgh.jai.lexer.JaiTokenTypes
|
||||
|
||||
/** Creates small Jai PSI fragments for AST replacements used by rename refactoring. */
|
||||
object JaiPsiElementFactory {
|
||||
fun createIdentifier(
|
||||
project: Project,
|
||||
name: String,
|
||||
): PsiElement {
|
||||
val file = PsiFileFactory.getInstance(project).createFileFromText("rename.jai", JaiLanguage, "$name :: 0;")
|
||||
val declarationName =
|
||||
PsiTreeUtil.findChildOfType(file, JaiDeclName::class.java)
|
||||
?: error("Could not parse a Jai declaration name for '$name'")
|
||||
return declarationName.node.findChildByType(JaiTokenTypes.IDENT)?.psi
|
||||
?: error("Jai name '$name' is not an identifier")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package dev.hgh.jai.psi.mixin
|
||||
|
||||
import com.intellij.extapi.psi.ASTWrapperPsiElement
|
||||
import com.intellij.lang.ASTNode
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiNameIdentifierOwner
|
||||
import dev.hgh.jai.lexer.JaiTokenTypes
|
||||
import dev.hgh.jai.psi.JaiPsiElementFactory
|
||||
|
||||
/** Gives declaration names the standard PSI naming contract used by rename/find-usages. */
|
||||
abstract class JaiNamedElementMixin(
|
||||
node: ASTNode,
|
||||
) : ASTWrapperPsiElement(node),
|
||||
PsiNameIdentifierOwner {
|
||||
override fun getNameIdentifier(): PsiElement? = node.findChildByType(JaiTokenTypes.IDENT)?.psi
|
||||
|
||||
override fun getName(): String? = nameIdentifier?.text
|
||||
|
||||
override fun setName(name: String): PsiElement {
|
||||
val identifier = nameIdentifier ?: return this
|
||||
identifier.replace(JaiPsiElementFactory.createIdentifier(project, name))
|
||||
return this
|
||||
}
|
||||
|
||||
override fun getNavigationElement(): PsiElement = nameIdentifier ?: this
|
||||
|
||||
override fun getTextOffset(): Int = nameIdentifier?.textOffset ?: super.getTextOffset()
|
||||
}
|
||||
26
src/main/kotlin/dev/hgh/jai/refactoring/JaiNamesValidator.kt
Normal file
26
src/main/kotlin/dev/hgh/jai/refactoring/JaiNamesValidator.kt
Normal file
@@ -0,0 +1,26 @@
|
||||
package dev.hgh.jai.refactoring
|
||||
|
||||
import com.intellij.lang.refactoring.NamesValidator
|
||||
import com.intellij.openapi.project.Project
|
||||
import dev.hgh.jai.lexer.JaiTokenTypes
|
||||
|
||||
/** Applies Jai's ASCII identifier and keyword rules to rename input. */
|
||||
class JaiNamesValidator : NamesValidator {
|
||||
override fun isKeyword(
|
||||
name: String,
|
||||
project: Project?,
|
||||
): Boolean = name in JaiTokenTypes.KEYWORD_MAP
|
||||
|
||||
override fun isIdentifier(
|
||||
name: String,
|
||||
project: Project?,
|
||||
): Boolean {
|
||||
if (name.isEmpty() || isKeyword(name, project)) return false
|
||||
if (!isIdentifierStart(name.first())) return false
|
||||
return name.drop(1).all(::isIdentifierPart)
|
||||
}
|
||||
|
||||
private fun isIdentifierStart(character: Char): Boolean = character in 'a'..'z' || character in 'A'..'Z' || character == '_'
|
||||
|
||||
private fun isIdentifierPart(character: Char): Boolean = isIdentifierStart(character) || character in '0'..'9'
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.hgh.jai.refactoring
|
||||
|
||||
import com.intellij.lang.refactoring.RefactoringSupportProvider
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiNameIdentifierOwner
|
||||
import dev.hgh.jai.JaiLanguage
|
||||
|
||||
/** Enables standard and in-place rename for Jai declaration names. */
|
||||
class JaiRefactoringSupportProvider : RefactoringSupportProvider() {
|
||||
override fun isMemberInplaceRenameAvailable(
|
||||
elementToRename: PsiElement,
|
||||
context: PsiElement?,
|
||||
): Boolean = elementToRename is PsiNameIdentifierOwner && elementToRename.language == JaiLanguage
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import dev.hgh.jai.psi.JaiDeclName
|
||||
import dev.hgh.jai.psi.JaiDeclaration
|
||||
import dev.hgh.jai.psi.JaiDirectiveExpr
|
||||
import dev.hgh.jai.psi.JaiLiteralExpr
|
||||
import dev.hgh.jai.psi.JaiPsiElementFactory
|
||||
import dev.hgh.jai.psi.JaiRefExpr
|
||||
|
||||
/** Adds file references for directives and symbol references for Jai identifiers. */
|
||||
@@ -106,6 +107,12 @@ private class JaiSymbolReference(
|
||||
) : PsiReferenceBase<JaiRefExpr>(sourceElement, TextRange(0, sourceElement.textLength), true) {
|
||||
override fun resolve(): PsiElement? = JaiSymbolResolver.resolve(sourceElement)
|
||||
|
||||
override fun handleElementRename(newElementName: String): PsiElement {
|
||||
val identifier = sourceElement.node.findChildByType(JaiTokenTypes.IDENT)?.psi ?: return sourceElement
|
||||
identifier.replace(JaiPsiElementFactory.createIdentifier(sourceElement.project, newElementName))
|
||||
return sourceElement
|
||||
}
|
||||
|
||||
override fun getVariants(): Array<Any> = emptyArray()
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,15 @@
|
||||
<completion.contributor
|
||||
language="Jai"
|
||||
implementationClass="dev.hgh.jai.completion.JaiCompletionContributor"/>
|
||||
<lang.refactoringSupport
|
||||
language="Jai"
|
||||
implementationClass="dev.hgh.jai.refactoring.JaiRefactoringSupportProvider"/>
|
||||
<lang.namesValidator
|
||||
language="Jai"
|
||||
implementationClass="dev.hgh.jai.refactoring.JaiNamesValidator"/>
|
||||
<lang.findUsagesProvider
|
||||
language="Jai"
|
||||
implementationClass="dev.hgh.jai.findusages.JaiFindUsagesProvider"/>
|
||||
</extensions>
|
||||
|
||||
</idea-plugin>
|
||||
|
||||
37
src/test/kotlin/dev/hgh/jai/findusages/JaiFindUsagesTest.kt
Normal file
37
src/test/kotlin/dev/hgh/jai/findusages/JaiFindUsagesTest.kt
Normal file
@@ -0,0 +1,37 @@
|
||||
package dev.hgh.jai.findusages
|
||||
|
||||
import com.intellij.psi.search.searches.ReferencesSearch
|
||||
import com.intellij.psi.util.PsiTreeUtil
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
import dev.hgh.jai.psi.JaiDeclName
|
||||
|
||||
class JaiFindUsagesTest : BasePlatformTestCase() {
|
||||
fun testFindUsagesActionFindsProcedureReference() {
|
||||
val usages = myFixture.testFindUsages("Declaration.jai", "Usage.jai")
|
||||
|
||||
assertEquals(1, usages.size)
|
||||
}
|
||||
|
||||
fun testFindsProcedureReferencesFromDeclaration() {
|
||||
val source =
|
||||
myFixture.addFileToProject(
|
||||
"find-usages.jai",
|
||||
"""
|
||||
helper :: () {}
|
||||
first :: () { helper(); }
|
||||
second :: () { helper(); }
|
||||
""".trimIndent(),
|
||||
)
|
||||
val declaration =
|
||||
PsiTreeUtil
|
||||
.findChildrenOfType(source, JaiDeclName::class.java)
|
||||
.single { it.text == "helper" }
|
||||
|
||||
val usages = ReferencesSearch.search(declaration).findAll()
|
||||
|
||||
assertEquals(2, usages.size)
|
||||
assertTrue(usages.all { it.element.text == "helper" })
|
||||
}
|
||||
|
||||
override fun getTestDataPath(): String = "src/test/testData/findusages"
|
||||
}
|
||||
59
src/test/kotlin/dev/hgh/jai/refactoring/JaiRenameTest.kt
Normal file
59
src/test/kotlin/dev/hgh/jai/refactoring/JaiRenameTest.kt
Normal file
@@ -0,0 +1,59 @@
|
||||
package dev.hgh.jai.refactoring
|
||||
|
||||
import com.intellij.psi.PsiNameIdentifierOwner
|
||||
import com.intellij.testFramework.fixtures.BasePlatformTestCase
|
||||
|
||||
class JaiRenameTest : BasePlatformTestCase() {
|
||||
fun testDeclarationNamesParticipateInRename() {
|
||||
myFixture.configureByText(
|
||||
"rename.jai",
|
||||
"""
|
||||
helper<caret> :: () {}
|
||||
main :: () { helper(); }
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val declaration = myFixture.elementAtCaret
|
||||
|
||||
assertTrue("declaration name should be a named PSI element", declaration is PsiNameIdentifierOwner)
|
||||
assertEquals("helper", (declaration as PsiNameIdentifierOwner).name)
|
||||
}
|
||||
|
||||
fun testRenameProcedureUpdatesSameFileReferences() {
|
||||
myFixture.configureByText(
|
||||
"rename.jai",
|
||||
"""
|
||||
helper<caret> :: () {}
|
||||
main :: () { helper(); }
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
myFixture.renameElementAtCaret("renamed")
|
||||
|
||||
myFixture.checkResult(
|
||||
"""
|
||||
renamed :: () {}
|
||||
main :: () { renamed(); }
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
|
||||
fun testRenameTypeUpdatesTypeReferences() {
|
||||
myFixture.configureByText(
|
||||
"rename-type.jai",
|
||||
"""
|
||||
Point<caret> :: struct { x: int; }
|
||||
use_point :: (point: Point) -> int { return point.x; }
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
myFixture.renameElementAtCaret("Coordinate")
|
||||
|
||||
myFixture.checkResult(
|
||||
"""
|
||||
Coordinate :: struct { x: int; }
|
||||
use_point :: (point: Coordinate) -> int { return point.x; }
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
}
|
||||
1
src/test/testData/findusages/Declaration.jai
Normal file
1
src/test/testData/findusages/Declaration.jai
Normal file
@@ -0,0 +1 @@
|
||||
helper<caret> :: () {}
|
||||
2
src/test/testData/findusages/Usage.jai
Normal file
2
src/test/testData/findusages/Usage.jai
Normal file
@@ -0,0 +1,2 @@
|
||||
#load "Declaration.jai";
|
||||
main :: () { helper(); }
|
||||
Reference in New Issue
Block a user