Fix more things that were broken
This commit is contained in:
parent
85e96264a8
commit
d7081d2b4e
7
kilo
7
kilo
@ -13,24 +13,25 @@ set_error_handler(static function (
|
||||
$errline
|
||||
) {
|
||||
$msg = print_r([
|
||||
'code' => $errno,
|
||||
'code' => error_code_name($errno),
|
||||
'message' => $errstr,
|
||||
'file' => $errfile,
|
||||
'line' => $errline,
|
||||
], TRUE);
|
||||
file_put_contents('error.log', $msg, FILE_APPEND);
|
||||
file_put_contents('kilo.log', $msg, FILE_APPEND);
|
||||
|
||||
return true;
|
||||
}, -1);
|
||||
set_exception_handler(static function (mixed $e) {
|
||||
$msg = print_r([
|
||||
'code' => $e->getCode(),
|
||||
'codeName' => error_code_name($e->getCode()),
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
], TRUE);
|
||||
file_put_contents('exception.log', $msg, FILE_APPEND);
|
||||
file_put_contents('kilo.log', $msg, FILE_APPEND);
|
||||
});
|
||||
|
||||
// ! Init with an IIFE
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
namespace Aviat\Kilo;
|
||||
|
||||
use Aviat\Kilo\Enum\KeyCode;
|
||||
use Aviat\Kilo\Tokens\PHP8;
|
||||
use Aviat\Kilo\Type\Point;
|
||||
|
||||
@ -39,13 +40,13 @@ class Document {
|
||||
return new self();
|
||||
}
|
||||
|
||||
protected function rowsToString(): string
|
||||
public function row(int $index): ?Row
|
||||
{
|
||||
$lines = array_map(fn (Row $row) => (string)$row, $this->rows);
|
||||
|
||||
return implode('', $lines);
|
||||
return (array_key_exists($index, $this->rows)) ? $this->rows[$index] : null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// ! File I/O
|
||||
// ------------------------------------------------------------------------
|
||||
@ -97,11 +98,13 @@ class Document {
|
||||
|
||||
$this->dirty = true;
|
||||
|
||||
if ($c === "\n")
|
||||
if ($c === KeyCode::ENTER || $c === KeyCode::CARRIAGE_RETURN)
|
||||
{
|
||||
$this->insertNewline($at);
|
||||
return;
|
||||
}
|
||||
else if ($at->y === $this->numRows)
|
||||
|
||||
if ($at->y === $this->numRows)
|
||||
{
|
||||
$this->insertRow($this->numRows, '');
|
||||
}
|
||||
@ -226,7 +229,7 @@ class Document {
|
||||
}
|
||||
}
|
||||
|
||||
public function selectSyntaxHighlight(): void
|
||||
protected function selectSyntaxHighlight(): void
|
||||
{
|
||||
if (empty($this->filename))
|
||||
{
|
||||
@ -241,6 +244,13 @@ class Document {
|
||||
$this->refreshSyntax();
|
||||
}
|
||||
|
||||
protected function rowsToString(): string
|
||||
{
|
||||
$lines = array_map(fn (Row $row) => (string)$row, $this->rows);
|
||||
|
||||
return implode('', $lines);
|
||||
}
|
||||
|
||||
public function refreshSyntax(): void
|
||||
{
|
||||
// Update the syntax highlighting for all the rows of the file
|
||||
|
@ -97,6 +97,10 @@ class Editor {
|
||||
$this->document = $maybeDocument;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->document = Document::new();
|
||||
}
|
||||
}
|
||||
|
||||
public function __debugInfo(): array
|
||||
@ -230,7 +234,13 @@ class Editor {
|
||||
|
||||
if ( ! empty($savedHl))
|
||||
{
|
||||
$this->document->rows[$savedHlLine]->hl = $savedHl;
|
||||
$row = $this->document->row($savedHlLine);
|
||||
|
||||
if ($row !== null)
|
||||
{
|
||||
$row->hl = $savedHl;
|
||||
}
|
||||
|
||||
$savedHl = [];
|
||||
}
|
||||
|
||||
@ -281,7 +291,11 @@ class Editor {
|
||||
$current = 0;
|
||||
}
|
||||
|
||||
$row =& $this->document->rows[$current];
|
||||
$row = $this->document->row($current);
|
||||
if ($row === null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
$match = strpos($row->render, $query);
|
||||
if ($match !== FALSE)
|
||||
@ -326,7 +340,13 @@ class Editor {
|
||||
$this->renderX = 0;
|
||||
if ($this->cursor->y < $this->document->numRows)
|
||||
{
|
||||
$this->renderX = $this->rowCxToRx($this->document->rows[$this->cursor->y], $this->cursor->x);
|
||||
$row = $this->document->row($this->cursor->y);
|
||||
|
||||
if ($row !== null)
|
||||
{
|
||||
$this->renderX = $this->rowCxToRx($row, $this->cursor->x);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Vertical Scrolling
|
||||
@ -368,7 +388,13 @@ class Editor {
|
||||
|
||||
protected function drawRow(int $rowIdx): void
|
||||
{
|
||||
$len = $this->document->rows[$rowIdx]->rsize - $this->offset->x;
|
||||
$row = $this->document->row($rowIdx);
|
||||
if ($row === null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
$len = $row->rsize - $this->offset->x;
|
||||
if ($len < 0)
|
||||
{
|
||||
$len = 0;
|
||||
@ -378,8 +404,8 @@ class Editor {
|
||||
$len = $this->terminalSize->cols;
|
||||
}
|
||||
|
||||
$chars = substr($this->document->rows[$rowIdx]->render, $this->offset->x, (int)$len);
|
||||
$hl = array_slice($this->document->rows[$rowIdx]->hl, $this->offset->x, (int)$len);
|
||||
$chars = substr($row->render, $this->offset->x, (int)$len);
|
||||
$hl = array_slice($row->hl, $this->offset->x, (int)$len);
|
||||
|
||||
$currentColor = -1;
|
||||
|
||||
@ -464,7 +490,7 @@ class Editor {
|
||||
|
||||
$statusFilename = $this->document->filename !== '' ? $this->document->filename : '[No Name]';
|
||||
$syntaxType = $this->document->fileType->name;
|
||||
$isDirty = $this->document->dirty ? '(modified)' : '';
|
||||
$isDirty = $this->document->isDirty() ? '(modified)' : '';
|
||||
$status = sprintf('%.20s - %d lines %s', $statusFilename, $this->document->numRows, $isDirty);
|
||||
$rstatus = sprintf('%s | %d/%d', $syntaxType, $this->cursor->y + 1, $this->document->numRows);
|
||||
$len = strlen($status);
|
||||
@ -492,12 +518,14 @@ class Editor {
|
||||
protected function drawMessageBar(): void
|
||||
{
|
||||
$this->outputBuffer .= ANSI::CLEAR_LINE;
|
||||
$len = strlen($this->statusMessage->text);
|
||||
$len = $this->statusMessage->len;
|
||||
if ($len > $this->terminalSize->cols)
|
||||
{
|
||||
$len = $this->terminalSize->cols;
|
||||
}
|
||||
|
||||
// If there is a message, and it's been less than 5 seconds since
|
||||
// last screen update, show the message
|
||||
if ($len > 0 && (time() - $this->statusMessage->time) < 5)
|
||||
{
|
||||
$this->outputBuffer .= substr($this->statusMessage->text, 0, $len);
|
||||
@ -530,7 +558,11 @@ class Editor {
|
||||
|
||||
public function setStatusMessage(string $fmt, mixed ...$args): void
|
||||
{
|
||||
$this->statusMessage = StatusMessage::from($fmt, ...$args);
|
||||
$text = func_num_args() > 1
|
||||
? sprintf($fmt, ...$args)
|
||||
: $fmt;
|
||||
|
||||
$this->statusMessage = StatusMessage::from($text);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
@ -546,17 +578,17 @@ class Editor {
|
||||
$this->setStatusMessage($prompt, $buffer);
|
||||
$this->refreshScreen();
|
||||
|
||||
$c = $this->readKey();
|
||||
$c = Terminal::readKey();
|
||||
$isModifier = in_array($c, $modifiers, TRUE);
|
||||
|
||||
if ($c === KeyType::ESCAPE || ($c === KeyType::ENTER && $buffer !== ''))
|
||||
if ($c === KeyType::ESCAPE || ($c === KeyCode::ENTER && $buffer !== ''))
|
||||
{
|
||||
$this->setStatusMessage('');
|
||||
if ($callback !== NULL)
|
||||
{
|
||||
$callback($buffer, $c);
|
||||
}
|
||||
return ($c === KeyType::ENTER) ? $buffer : '';
|
||||
return ($c === KeyCode::ENTER) ? $buffer : '';
|
||||
}
|
||||
|
||||
if ($c === KeyType::DEL_KEY || $c === KeyType::BACKSPACE)
|
||||
@ -579,7 +611,11 @@ class Editor {
|
||||
{
|
||||
$x = $this->cursor->x;
|
||||
$y = $this->cursor->y;
|
||||
$row = $this->document->rows[$y];
|
||||
$row = $this->document->row($y);
|
||||
if ($row === NULL)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch ($key)
|
||||
{
|
||||
@ -592,16 +628,16 @@ class Editor {
|
||||
{
|
||||
// Beginning of a line, go to end of previous line
|
||||
$y--;
|
||||
$x = $this->document->rows[$y]->size - 1;
|
||||
$x = $row->size - 1;
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyType::ARROW_RIGHT:
|
||||
if ($row && $x < $row->size)
|
||||
if ($x < $row->size)
|
||||
{
|
||||
$x++;
|
||||
}
|
||||
else if ($row && $x === $row->size)
|
||||
else if ($x === $row->size)
|
||||
{
|
||||
$y++;
|
||||
$x = 0;
|
||||
@ -641,7 +677,7 @@ class Editor {
|
||||
case KeyType::END_KEY:
|
||||
if ($y < $this->document->numRows)
|
||||
{
|
||||
$x = $this->document->rows[$y]->size;
|
||||
$x = $row->size;
|
||||
}
|
||||
break;
|
||||
|
||||
@ -651,16 +687,18 @@ class Editor {
|
||||
|
||||
// Snap cursor to the end of a row when moving
|
||||
// from a longer row to a shorter one
|
||||
$row = $this->document->rows[$y];
|
||||
$rowLen = ($row !== NULL) ? $row->size : 0;
|
||||
if ($x > $rowLen)
|
||||
$row = $this->document->row($y);
|
||||
if ($row !== null)
|
||||
{
|
||||
$x = $rowLen;
|
||||
if ($x > $row->size)
|
||||
{
|
||||
$x = $row->size;
|
||||
}
|
||||
|
||||
$this->cursor->x = $x;
|
||||
$this->cursor->y = $y;
|
||||
}
|
||||
}
|
||||
|
||||
protected function processKeypress(): void
|
||||
{
|
||||
@ -713,6 +751,10 @@ class Editor {
|
||||
// Do nothing
|
||||
break;
|
||||
|
||||
case KeyType::ENTER:
|
||||
$this->insertChar("\n");
|
||||
break;
|
||||
|
||||
default:
|
||||
$this->insertChar($c);
|
||||
break;
|
||||
@ -728,7 +770,7 @@ class Editor {
|
||||
|
||||
protected function quitAttempt(): void
|
||||
{
|
||||
if ($this->document->dirty && $this->quitTimes > 0)
|
||||
if ($this->document->isDirty() && $this->quitTimes > 0)
|
||||
{
|
||||
$this->setStatusMessage(
|
||||
'WARNING!!! File has unsaved changes. Press Ctrl-Q %d more times to quit.',
|
||||
|
@ -17,6 +17,7 @@ class KeyCode {
|
||||
public const ARROW_RIGHT = "\e[C";
|
||||
public const ARROW_UP = "\e[A";
|
||||
public const BACKSPACE = "\x7f";
|
||||
public const BELL = "\a";
|
||||
public const CARRIAGE_RETURN = "\r";
|
||||
public const DEL_KEY = "\e[3~";
|
||||
public const EMPTY = '';
|
||||
|
@ -11,15 +11,16 @@ use Aviat\Kilo\Traits;
|
||||
class KeyType {
|
||||
use Traits\ConstList;
|
||||
|
||||
public const ARROW_DOWN = 'ARROW_DOWN';
|
||||
public const ARROW_LEFT = 'ARROW_LEFT';
|
||||
public const ARROW_RIGHT = 'ARROW_RIGHT';
|
||||
public const ARROW_UP = 'ARROW_UP';
|
||||
public const BACKSPACE = 'BACKSPACE';
|
||||
public const DEL_KEY = 'DELETE';
|
||||
public const END_KEY = 'END';
|
||||
public const ESCAPE = 'ESCAPE';
|
||||
public const HOME_KEY = 'HOME';
|
||||
public const PAGE_DOWN = 'PAGE_DOWN';
|
||||
public const PAGE_UP = 'PAGE_UP';
|
||||
public const ARROW_DOWN = 'KEY_ARROW_DOWN';
|
||||
public const ARROW_LEFT = 'KEY_ARROW_LEFT';
|
||||
public const ARROW_RIGHT = 'KEY_ARROW_RIGHT';
|
||||
public const ARROW_UP = 'KEY_ARROW_UP';
|
||||
public const BACKSPACE = 'KEY_BACKSPACE';
|
||||
public const DEL_KEY = 'KEY_DELETE';
|
||||
public const END_KEY = 'KEY_END';
|
||||
public const ENTER = 'KEY_ENTER';
|
||||
public const ESCAPE = 'KEY_ESCAPE';
|
||||
public const HOME_KEY = 'KEY_HOME';
|
||||
public const PAGE_DOWN = 'KEY_PAGE_DOWN';
|
||||
public const PAGE_UP = 'KEY_PAGE_UP';
|
||||
}
|
@ -13,15 +13,14 @@ class FileType {
|
||||
|
||||
private static function getSyntaxFromFilename(string $filename): Syntax
|
||||
{
|
||||
$ext = (string)strstr(basename($filename), '.');
|
||||
$ext = strstr(basename($filename), '.');
|
||||
$ext = ($ext !== FALSE) ? $ext : '';
|
||||
return match ($ext) {
|
||||
'.php', 'kilo' => Syntax::new(
|
||||
'PHP',
|
||||
['.php', 'kilo'],
|
||||
),
|
||||
'.c', '.h', '.cpp', '.cxx', '.cc', '.hpp' => Syntax::new(
|
||||
'C',
|
||||
['.c', '.h', '.cpp'],
|
||||
[
|
||||
'continue', 'typedef', 'switch', 'return', 'static', 'while', 'break', 'struct',
|
||||
'union', 'class', 'else', 'enum', 'for', 'case', 'if',
|
||||
@ -33,12 +32,10 @@ class FileType {
|
||||
),
|
||||
'.css', '.less', '.sass', '.scss' => Syntax::new(
|
||||
'CSS',
|
||||
['.css', '.less', '.sass', 'scss'],
|
||||
slcs: '',
|
||||
),
|
||||
'.js', '.jsx', '.ts', '.tsx', '.jsm', '.mjs', '.es' => Syntax::new(
|
||||
'JavaScript',
|
||||
['.js', '.jsx', '.ts', '.tsx', '.jsm', '.mjs', '.es'],
|
||||
[
|
||||
'instanceof', 'continue', 'debugger', 'function', 'default', 'extends',
|
||||
'finally', 'delete', 'export', 'import', 'return', 'switch', 'typeof',
|
||||
@ -52,7 +49,6 @@ class FileType {
|
||||
),
|
||||
'.rs' => Syntax::new(
|
||||
'Rust',
|
||||
['.rs'],
|
||||
[
|
||||
'continue', 'return', 'static', 'struct', 'unsafe', 'break', 'const', 'crate',
|
||||
'extern', 'match', 'super', 'trait', 'where', 'else', 'enum', 'false', 'impl',
|
||||
|
@ -11,7 +11,6 @@ class Syntax {
|
||||
|
||||
public static function new(
|
||||
string $name,
|
||||
array $extList = [],
|
||||
array $keywords1 = [],
|
||||
array $keywords2 = [],
|
||||
string $slcs = '//',
|
||||
@ -20,7 +19,7 @@ class Syntax {
|
||||
int $flags = self::HIGHLIGHT_NUMBERS | self::HIGHLIGHT_STRINGS,
|
||||
): self
|
||||
{
|
||||
return new self($name, $extList, $keywords1, $keywords2, $slcs, $mcs, $mce, $flags);
|
||||
return new self($name, $keywords1, $keywords2, $slcs, $mcs, $mce, $flags);
|
||||
}
|
||||
|
||||
public static function default(): self
|
||||
@ -31,8 +30,6 @@ class Syntax {
|
||||
private function __construct(
|
||||
/** The name of the programming language */
|
||||
public string $filetype,
|
||||
/** Relevant file extensions for the specified language */
|
||||
public array $filematch,
|
||||
/** Primary set of language keywords */
|
||||
public array $keywords1,
|
||||
/** Secondary set of language keywords */
|
||||
|
@ -95,6 +95,7 @@ class Terminal {
|
||||
KeyCode::ARROW_RIGHT => KeyType::ARROW_RIGHT,
|
||||
KeyCode::ARROW_UP => KeyType::ARROW_UP,
|
||||
KeyCode::DEL_KEY => KeyType::DEL_KEY,
|
||||
KeyCode::ENTER => KeyType::ENTER,
|
||||
KeyCode::PAGE_DOWN => KeyType::PAGE_DOWN,
|
||||
KeyCode::PAGE_UP => KeyType::PAGE_UP,
|
||||
|
||||
|
@ -5,11 +5,12 @@ namespace Aviat\Kilo\Type;
|
||||
class StatusMessage {
|
||||
private function __construct(
|
||||
public string $text,
|
||||
public int $len,
|
||||
public int $time,
|
||||
) {}
|
||||
|
||||
public static function from(string $text, mixed ...$args): self
|
||||
public static function from(string $text): self
|
||||
{
|
||||
return new self(sprintf($text, ...$args), time());
|
||||
return new self($text, strlen($text), time());
|
||||
}
|
||||
}
|
@ -187,3 +187,24 @@ function tabs_to_spaces(string $str, int $number = KILO_TAB_STOP): string
|
||||
{
|
||||
return str_replace(KeyCode::TAB, str_repeat(KeyCode::SPACE, $number), $str);
|
||||
}
|
||||
|
||||
function error_code_name(int $code): string
|
||||
{
|
||||
return match ($code) {
|
||||
E_ERROR => 'Error',
|
||||
E_WARNING => 'Warning',
|
||||
E_PARSE => 'Parse Error',
|
||||
E_NOTICE => 'Notice',
|
||||
E_CORE_ERROR => 'Core Error',
|
||||
E_CORE_WARNING => 'Core Warning',
|
||||
E_COMPILE_ERROR => 'Compile Error',
|
||||
E_COMPILE_WARNING => 'Compile Warning',
|
||||
E_USER_ERROR => 'User Error',
|
||||
E_USER_WARNING => 'User Warning',
|
||||
E_USER_NOTICE => 'User Notice',
|
||||
E_RECOVERABLE_ERROR => 'Recoverable Error',
|
||||
E_DEPRECATED => 'Deprecated',
|
||||
E_USER_DEPRECATED => 'User Deprecated',
|
||||
default => 'Unknown',
|
||||
};
|
||||
}
|
@ -29,14 +29,6 @@ class MockEditor extends Editor {
|
||||
class EditorTest extends TestCase {
|
||||
use MatchesSnapshots;
|
||||
|
||||
public function testSanity(): void
|
||||
{
|
||||
$editor = MockEditor::mock();
|
||||
|
||||
$this->assertEquals(0, $editor->numRows);
|
||||
$this->assertNull($editor->syntax);
|
||||
}
|
||||
|
||||
public function test__debugInfo(): void
|
||||
{
|
||||
$editor = MockEditor::mock();
|
||||
|
@ -8,7 +8,6 @@ use PHPUnit\Framework\TestCase;
|
||||
|
||||
use function Aviat\Kilo\array_replace_range;
|
||||
use function Aviat\Kilo\ctrl_key;
|
||||
use function Aviat\Kilo\get_file_syntax_map;
|
||||
use function Aviat\Kilo\is_ascii;
|
||||
use function Aviat\Kilo\is_ctrl;
|
||||
use function Aviat\Kilo\is_digit;
|
||||
@ -97,11 +96,6 @@ class FunctionTest extends TestCase {
|
||||
$this->assertNotEquals(syntax_to_color(Highlight::OPERATOR), Color::FG_WHITE);
|
||||
}
|
||||
|
||||
public function test_get_file_syntax_map(): void
|
||||
{
|
||||
$this->assertNotEmpty(get_file_syntax_map());
|
||||
}
|
||||
|
||||
public function test_str_contains(): void
|
||||
{
|
||||
// Search from string offset
|
||||
|
@ -2,19 +2,22 @@
|
||||
|
||||
namespace Aviat\Kilo\Tests\Traits;
|
||||
|
||||
use Aviat\Kilo\{Editor, Row};
|
||||
use Aviat\Kilo\
|
||||
{
|
||||
Document,
|
||||
Row};
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class RowTest extends TestCase {
|
||||
protected Editor $editor;
|
||||
protected Document $document;
|
||||
protected Row $row;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->editor = Editor::new();
|
||||
$this->row = Row::new($this->editor, '', 0);
|
||||
$this->document = Document::new();
|
||||
$this->row = Row::new($this->document, '', 0);
|
||||
}
|
||||
|
||||
public function testSanity(): void
|
||||
@ -53,42 +56,42 @@ class RowTest extends TestCase {
|
||||
$this->assertEquals($expected, $actual);
|
||||
}
|
||||
|
||||
public function testInsertChar(): void
|
||||
public function testInsert(): void
|
||||
{
|
||||
$this->row->chars = 'abde';
|
||||
$this->row->insertChar(2, 'c');
|
||||
$this->row->insert(2, 'c');
|
||||
|
||||
$this->assertEquals('abcde', $this->row->chars);
|
||||
$this->assertEquals('abcde', $this->row->render);
|
||||
$this->assertEquals(true, $this->editor->dirty);
|
||||
$this->assertEquals(true, $this->document->dirty);
|
||||
}
|
||||
|
||||
public function testInsertCharBadOffset(): void
|
||||
public function testInsertBadOffset(): void
|
||||
{
|
||||
$this->row->chars = 'ab';
|
||||
$this->row->insertChar(5, 'c');
|
||||
$this->row->insert(5, 'c');
|
||||
|
||||
$this->assertEquals('abc', $this->row->chars);
|
||||
$this->assertEquals('abc', $this->row->render);
|
||||
$this->assertEquals(true, $this->editor->dirty);
|
||||
$this->assertEquals(true, $this->document->dirty);
|
||||
}
|
||||
|
||||
public function testDeleteChar(): void
|
||||
public function testDelete(): void
|
||||
{
|
||||
$this->row->chars = 'abcdef';
|
||||
$this->row->deleteChar(5);
|
||||
$this->row->delete(5);
|
||||
|
||||
$this->assertEquals('abcde', $this->row->chars);
|
||||
$this->assertEquals('abcde', $this->row->render);
|
||||
$this->assertEquals(true, $this->editor->dirty);
|
||||
$this->assertEquals(true, $this->document->dirty);
|
||||
}
|
||||
|
||||
public function testDeleteCharBadOffset(): void
|
||||
public function testDeleteBadOffset(): void
|
||||
{
|
||||
$this->row->chars = 'ab';
|
||||
$this->row->deleteChar(5);
|
||||
$this->row->delete(5);
|
||||
|
||||
$this->assertEquals('ab', $this->row->chars);
|
||||
$this->assertEquals(false, $this->editor->dirty);
|
||||
$this->assertEquals(false, $this->document->dirty);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user