diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4095d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +tests/results.txt diff --git a/README.md b/README.md index df06aa0..f5a06ec 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,248 @@ # Logger -Shared message logging mechanism for vim plugins written in vim9script. + +Logger shows and logs the messages of Vim9 plugins. Each message names the +plugin, script, and method that sent it. Logger can also write the messages +of each plugin to its own log file. + + + +[![Vim](https://img.shields.io/badge/Vim-9.1%2B-019733?logo=vim)](https://www.vim.org) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) + +**Contents** + +- [Requirements](#requirements) +- [Installation](#installation) +- [Quick start](#quick-start) +- [Usage](#usage) +- [Configuration](#configuration) +- [Log files](#log-files) +- [Commands](#commands) +- [Development](#development) +- [License](#license) + + + +## Requirements + +Vim 9.1 or later. Logger does not load in Vim 9.0, in Neovim, or in +compatible mode. + +## Installation + +Install Logger together with each plugin that uses it. + +With Vim packages: + +```bash +git clone https://github.com/Galvarcus/Logger.git ~/.vim/pack/vendor/start/Logger +``` + +With vim-plug: + +```vim +Plug 'Galvarcus/Logger' +``` + +## Quick start + +Import Logger at the top of a script, and make one `Logger` for the script: + +```vim +vim9script + +import 'Logger/logger.vim' as Log + +var log = Log.Logger.new('MyPlugin', expand(':t')) + +def Save() + log.Info('Saved.') +enddef +``` + +A call to `Save()` in `foo.vim` echoes this message: + +```text +MyPlugin - foo.vim - Save: Saved. +``` + +## Usage + +### Logger objects + +`Log.Logger.new({plugin}, {script}, {level})` makes a `Logger`. All +arguments are optional. + +| Argument | Value | +|---|---| +| `{plugin}` | Plugin name. Options for the plugin use this name. | +| `{script}` | Script name, usually `expand(':t')`. | +| `{level}` | Echo level for this `Logger`. Options can override it. | + +Make one `Logger` in each script. All `Logger` objects with the same +plugin name use the same options and the same log file. + +### Methods + +| Method | Effect | +|----------------------------------------|----------------------------------| +| `Info({msg})` | Show an info message, as `:echom` does. | +| `Warn({msg})` | Show a warning with `WarningMsg` highlight. | +| `Error({msg})` | Show an error with `ErrorMsg` highlight. | +| `Exception({exception}, {throwpoint})` | Show a caught exception. The defaults are `v:exception` and `v:throwpoint`. | +| `Fmt({msg})` | Return an exception text to throw. | +| `Try({Fn})` | Call `{Fn}` and return its result. On an exception, show it and return `v:none`. | + +Each method also takes an optional last argument, `{method}`. When it is +empty, Logger uses the name of the function that called it. A class method shows as `Class.Method`. Code at +script level and code in a lambda have no method name. + +### Message format + +An echoed message has this form: + +```text +MyPlugin - foo.vim - Save: [WARN] Disk almost full. +``` + +Logger omits an empty plugin, script, or method name. The tags are +`[WARN]`, `[ERROR]`, and `[EXCEPTION]`. An info message has no tag. An +exception also shows its throwpoint on a second line. + +### Exceptions + +Catch an exception and show it: + +```vim +try + Risky() +catch + log.Exception() +endtry +``` + +`Try()` does the same in one line: + +```vim +var result = log.Try(() => Risky()) +``` + +To throw where no `try` is near, use `Fmt()`: + +```vim +if exists('g:this') + Use(g:this) +elseif exists('g:that') + Use(g:that) +else + throw log.Fmt('Neither this nor that.') +endif +``` + +`Fmt()` records and logs the message when you throw it. Vim shows an +uncaught exception without Logger, so this puts it in the log file too. +When a caller catches the text and calls `Exception()`, Logger shows the +text once and does not record it again. + +### Levels + +| Level | Messages | +|---|---| +| `-1` | None | +| `0` | Errors and exceptions | +| `1` | Warnings, errors, and exceptions | +| `2` | All | + +### Startup messages + +Logger holds messages sent before `VimEnter` and echoes them after Vim +starts.[^1] + +## Configuration + +Each option has a global variable, `g:logger_{option}`, and a variable for +each plugin, `g:logger_{plugin}_{option}`. `{plugin}` is the plugin name in +lowercase, with each character other than a letter, digit, or underscore +changed to `_`. For the plugin `My-Plugin`, it is `my_plugin`. + +Logger uses the first value it finds: + +1. The plugin variable, such as `g:logger_myplugin_level`. +2. The `{level}` argument of `Logger.new()`, for `level` only. +3. The global variable, such as `g:logger_level`. +4. The default. + +Logger reads the options on each message, so a change applies at once. A +value of the wrong type uses the default. + +| Option | Default | Effect | +|-----------------|--------------|-------------------------------------------| +| `echo` | `true` | Echo messages. | +| `level` | `2` | Echo level. | +| `log_file` | `false` | Write messages to a log file. | +| `log_level` | `2` | Log file level. | +| `log_retention` | `10` | Number of old log files to keep for each plugin. `0` keeps all. | +| `log_dir` | `'~/.Logger'` | Log file folder. Global only. | + +This example logs all plugins, keeps 20 old files for `MyPlugin`, and shows +only its errors and warnings: + +```vim +g:logger_log_file = true +g:logger_myplugin_log_retention = 20 +g:logger_myplugin_level = 1 +``` + +## Log files + +With `log_file` on, Logger writes the messages of each plugin to one file +for each Vim session: + +```text +~/.Logger/myplugin_20260926_140311.log +``` + +Logger makes the folder and the file on the first message of the session. +It then deletes the oldest files of that plugin until `log_retention` old +files remain. The current file does not count. + +Each message is one line. An exception adds its throwpoint on the next +line: + +```text +2026-09-26 14:03:11 WARN MyPlugin - foo.vim - Save: Disk almost full. +2026-09-26 14:03:12 EXCEPTION MyPlugin - foo.vim - Load: No config file. + at function 12_Load, line 3 +``` + +When Logger cannot write the file, it shows one warning. That `Logger` +then stops writing to the file until Vim restarts. + +## Commands + +`:LoggerMessages` opens a scratch window with the last 200 messages of all +plugins. + + + +## Development + +Run the tests from the root of the repository: + +```bash +vim -es -u NONE -N -c 'set rtp+=.' -c 'source tests/harness.vim' -c 'quit' +cat tests/results.txt +``` + +The `Tests` workflow runs the tests on the newest Vim and on Vim 9.1.0016. +The `Generate Vimdoc` workflow makes `doc/logger.txt` from this file on +each push to `main` and `Development`. Edit this file, not +`doc/logger.txt`. You can also start both workflows by hand. + + + +## License + +GNU General Public License 3.0. The full text is in `LICENSE`. + +[^1]: Vim redraws the screen during startup, which hides earlier messages. diff --git a/doc/logger.txt b/doc/logger.txt new file mode 100644 index 0000000..b4441ee --- /dev/null +++ b/doc/logger.txt @@ -0,0 +1,257 @@ +*logger.txt* Shows and logs the messages of Vim9 plugins. + + Logger by Galvarcus + + + Type |gO| to see the table of contents. + +Logger ......................................................... |logger-logger| + Requirements ........................................... |logger-requirements| + Installation ........................................... |logger-installation| + Quick start ............................................. |logger-quick-start| + Usage ......................................................... |logger-usage| + Logger objects ..................................... |logger-logger-objects| + Methods ................................................... |logger-methods| + Message format ..................................... |logger-message-format| + Exceptions ............................................. |logger-exceptions| + Levels ..................................................... |logger-levels| + Startup messages ................................. |logger-startup-messages| + Configuration ......................................... |logger-configuration| + Log files ................................................. |logger-log-files| + Commands ................................................... |logger-commands| + License ..................................................... |logger-license| + +============================================================================== +Logger *logger-logger* + +Logger shows and logs the messages of Vim9 plugins. Each message names the +plugin, script, and method that sent it. Logger can also write the messages of +each plugin to its own log file. + +------------------------------------------------------------------------------ +Requirements *logger-requirements* + +Vim 9.1 or later. Logger does not load in Vim 9.0, in Neovim, or in compatible +mode. + +------------------------------------------------------------------------------ +Installation *logger-installation* + +Install Logger together with each plugin that uses it. + +With Vim packages: + +>bash + git clone https://github.com/Galvarcus/Logger.git ~/.vim/pack/vendor/start/Logger +< +With vim-plug: + +>vim + Plug 'Galvarcus/Logger' +< + +------------------------------------------------------------------------------ +Quick start *logger-quick-start* + +Import Logger at the top of a script, and make one `Logger` for the script: + +>vim + vim9script + + import 'Logger/logger.vim' as Log + + var log = Log.Logger.new('MyPlugin', expand(':t')) + + def Save() + log.Info('Saved.') + enddef +< +A call to `Save()` in `foo.vim` echoes this message: + +>text + MyPlugin - foo.vim - Save: Saved. +< + +------------------------------------------------------------------------------ +Usage *logger-usage* + +LOGGER OBJECTS *logger-logger-objects* + +`Log.Logger.new({plugin}, {script}, {level})` makes a `Logger`. All arguments +are optional. + + Argument Value ~ + `{plugin}` Plugin name. Options for the plugin use this name. + `{script}` Script name, usually `expand(':t')`. + `{level}` Echo level for this `Logger`. Options can override it. + +Make one `Logger` in each script. All `Logger` objects with the same plugin +name use the same options and the same log file. + +METHODS *logger-methods* + + Method Effect ~ + + `Info({msg})` Show an info message, as + `:echom` does. + + `Warn({msg})` Show a warning with + `WarningMsg` highlight. + + `Error({msg})` Show an error with `ErrorMsg` + highlight. + + `Exception({exception}, {throwpoint})` Show a caught exception. The + defaults are `v:exception` and + `v:throwpoint`. + + `Fmt({msg})` Return an exception text to + throw. + + `Try({Fn})` Call `{Fn}` and return its + result. On an exception, show + it and return `v:none`. + +Each method also takes an optional last argument, `{method}`. When it is +empty, Logger uses the name of the function that called it. A class method +shows as `Class.Method`. Code at script level and code in a lambda have no +method name. + +MESSAGE FORMAT *logger-message-format* + +An echoed message has this form: + +>text + MyPlugin - foo.vim - Save: [WARN] Disk almost full. +< +Logger omits an empty plugin, script, or method name. The tags are `[WARN]`, +`[ERROR]`, and `[EXCEPTION]`. An info message has no tag. An exception also +shows its throwpoint on a second line. + +EXCEPTIONS *logger-exceptions* + +Catch an exception and show it: + +>vim + try + Risky() + catch + log.Exception() + endtry +< +`Try()` does the same in one line: + +>vim + var result = log.Try(() => Risky()) +< +To throw where no `try` is near, use `Fmt()`: + +>vim + if exists('g:this') + Use(g:this) + elseif exists('g:that') + Use(g:that) + else + throw log.Fmt('Neither this nor that.') + endif +< +`Fmt()` records and logs the message when you throw it. Vim shows an uncaught +exception without Logger, so this puts it in the log file too. When a caller +catches the text and calls `Exception()`, Logger shows the text once and does +not record it again. + +LEVELS *logger-levels* + + Level Messages ~ + `-1` None + `0` Errors and exceptions + `1` Warnings, errors, and exceptions + `2` All + +STARTUP MESSAGES *logger-startup-messages* + +Logger holds messages sent before `VimEnter` and echoes them after Vim starts. +|logger-footnote1| + +------------------------------------------------------------------------------ +Configuration *logger-configuration* + +Each option has a global variable, `g:logger_{option}`, and a variable for +each plugin, `g:logger_{plugin}_{option}`. `{plugin}` is the plugin name in +lowercase, with each character other than a letter, digit, or underscore +changed to `_`. For the plugin `My-Plugin`, it is `my_plugin`. + +Logger uses the first value it finds: + +1. The plugin variable, such as `g:logger_myplugin_level`. +2. The `{level}` argument of `Logger.new()`, for `level` only. +3. The global variable, such as `g:logger_level`. +4. The default. + +Logger reads the options on each message, so a change applies at once. A value +of the wrong type uses the default. + + Option Default Effect ~ + + `echo` `true` Echo messages. + + `level` `2` Echo level. + + `log_file` `false` Write messages to a log file. + + `log_level` `2` Log file level. + + `log_retention` `10` Number of old log files to keep for + each plugin. `0` keeps all. + + `log_dir` `'~/.Logger'` Log file folder. Global only. + +This example logs all plugins, keeps 20 old files for `MyPlugin`, and shows +only its errors and warnings: + +>vim + g:logger_log_file = true + g:logger_myplugin_log_retention = 20 + g:logger_myplugin_level = 1 +< + +------------------------------------------------------------------------------ +Log files *logger-log-files* + +With `log_file` on, Logger writes the messages of each plugin to one file for +each Vim session: + +>text + ~/.Logger/myplugin_20260926_140311.log +< +Logger makes the folder and the file on the first message of the session. It +then deletes the oldest files of that plugin until `log_retention` old files +remain. The current file does not count. + +Each message is one line. An exception adds its throwpoint on the next line: + +>text + 2026-09-26 14:03:11 WARN MyPlugin - foo.vim - Save: Disk almost full. + 2026-09-26 14:03:12 EXCEPTION MyPlugin - foo.vim - Load: No config file. + at function 12_Load, line 3 +< +When Logger cannot write the file, it shows one warning. That `Logger` then +stops writing to the file until Vim restarts. + +------------------------------------------------------------------------------ +Commands *logger-commands* + +`:LoggerMessages` opens a scratch window with the last 200 messages of all +plugins. + +------------------------------------------------------------------------------ +License *logger-license* + +GNU General Public License 3.0. The full text is in `LICENSE`. + + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + + *logger-footnote1* +Vim redraws the screen during startup, which hides earlier messages. + + vim:tw=78:sw=4:ts=4:ft=help:norl:et: diff --git a/doc/pandoc.yaml b/doc/pandoc.yaml new file mode 100644 index 0000000..0a0e537 --- /dev/null +++ b/doc/pandoc.yaml @@ -0,0 +1,5 @@ +title: Logger +author: Galvarcus +abstract: Shows and logs the messages of Vim9 plugins. +filename: logger.txt +vimdoc-prefix: logger diff --git a/doc/vimdoc-filter.lua b/doc/vimdoc-filter.lua new file mode 100644 index 0000000..842069f --- /dev/null +++ b/doc/vimdoc-filter.lua @@ -0,0 +1,32 @@ +-- Remove content between vimdoc ignore markers and omit images. +local ignoring = false + +function Pandoc(doc) + local out = {} + + for _, block in ipairs(doc.blocks) do + if block.t == "RawBlock" and block.format == "html" then + local html = block.text + if html:match("^%s*%s*$") then + ignoring = true + elseif html:match("^%s*%s*$") then + ignoring = false + elseif not ignoring then + table.insert(out, block) + end + elseif not ignoring then + if block.t == "Para" then + local inlines = {} + for _, inline in ipairs(block.content) do + if inline.t ~= "Image" then table.insert(inlines, inline) end + end + if #inlines > 0 then table.insert(out, pandoc.Para(inlines)) end + elseif block.t ~= "Image" then + table.insert(out, block) + end + end + end + + doc.blocks = out + return doc +end diff --git a/import/Logger/config.vim b/import/Logger/config.vim new file mode 100644 index 0000000..824e809 --- /dev/null +++ b/import/Logger/config.vim @@ -0,0 +1,76 @@ +vim9script + +if exists('s:is_loaded') || v:version < 901 || &cp + finish +endif + +var is_loaded: bool = true + +############################################################################## +# Plugin_Name: Logger +# Reads Logger options for one plugin. A plugin option such as +# g:logger_foo_log_retention overrides the global g:logger_log_retention, +# which overrides the default the caller gives. Values are read on each +# call, so a change applies without a restart. +# License: GNU GPL 3.0 +############################################################################## + +# CLASS: Option lookup for one plugin. +export class Config + var key: string + + # METHOD: Make the plugin name safe for a variable or file name. Foo + # becomes foo and My-Plugin becomes my_plugin. + def new(plugin: string) + this.key = plugin->tolower()->substitute('[^a-z0-9_]', '_', 'g') + enddef + + # METHOD: Return the plugin variable name for an option. + def Name(option: string): string + return $'logger_{this.key}_{option}' + enddef + + # METHOD: Return true when the plugin variable for an option exists. + def HasOwn(option: string): bool + return has_key(g:, this.Name(option)) + enddef + + # METHOD: Return the plugin value, else the global value, else default. + # With perPlugin false, skip the plugin value. + def Get(option: string, default: any, perPlugin: bool = true): any + if perPlugin && this.HasOwn(option) + return get(g:, this.Name(option)) + endif + return get(g:, $'logger_{option}', default) + enddef + + # METHOD: Return a number option. Accept a bool as 0 or 1. Return + # default for any other type. + def GetNumber(option: string, default: number): number + var value: any = this.Get(option, default) + if type(value) == v:t_number + return value + elseif type(value) == v:t_bool + return value ? 1 : 0 + endif + return default + enddef + + # METHOD: Return a bool option. Accept a number, nonzero is true. + # Return default for any other type. + def GetBool(option: string, default: bool): bool + var value: any = this.Get(option, default) + if type(value) == v:t_bool + return value + elseif type(value) == v:t_number + return value != 0 + endif + return default + enddef + + # METHOD: Return a string option. Return default for any other type. + def GetString(option: string, default: string, perPlugin: bool = true): string + var value: any = this.Get(option, default, perPlugin) + return type(value) == v:t_string ? value : default + enddef +endclass diff --git a/import/Logger/logfile.vim b/import/Logger/logfile.vim new file mode 100644 index 0000000..355c463 --- /dev/null +++ b/import/Logger/logfile.vim @@ -0,0 +1,64 @@ +vim9script + +if exists('s:is_loaded') || v:version < 901 || &cp + finish +endif + +var is_loaded: bool = true + +############################################################################## +# Plugin_Name: Logger +# Writes the messages of one plugin to one file for each Vim session, such +# as foo_20260926_140311.log. On the first write of a session, deletes the +# oldest files of that plugin so that only the given number of old files +# remain. +# License: GNU GPL 3.0 +############################################################################## + +# All plugins share one timestamp, so the files of one session sort together. +var session: string = strftime('%Y%m%d_%H%M%S') + +# CLASS: Session log file for one plugin. +export class LogFile + var key: string + var folder: string = '' + var path: string = '' + + def new(this.key) + enddef + + # METHOD: Append lines to the session file in dir. Create dir when it + # does not exist. Start the file on the first write, and again when dir + # changes. Throws when Vim cannot write the file. + def Write(lines: list, dir: string, retention: number): void + var expanded: string = expand(dir) + if !isdirectory(expanded) + mkdir(expanded, 'p') + endif + # Only an existing folder gets a trailing separator from :p. + var target: string = fnamemodify(expanded, ':p') + if target !=# this.folder + this.folder = target + this.path = $'{target}{this.key}_{session}.log' + writefile([], this.path, 'a') + this.Prune(retention) + endif + writefile(lines, this.path, 'a') + enddef + + # METHOD: Delete the oldest files of this plugin until retention old + # files remain. The current file does not count. Zero or less keeps all. + def Prune(retention: number): void + if retention <= 0 + return + endif + var current: string = fnamemodify(this.path, ':t') + # The exact pattern keeps foo from matching the files of foo_bar. + var pattern: string = printf('^%s_\d\{8}_\d\{6}\.log$', this.key) + var old: list = readdir(this.folder, + (n) => n =~# pattern && n !=# current)->sort() + for f in old[: -retention - 1] + delete($'{this.folder}{f}') + endfor + enddef +endclass diff --git a/import/Logger/logger.vim b/import/Logger/logger.vim index 7f4b357..d51f618 100644 --- a/import/Logger/logger.vim +++ b/import/Logger/logger.vim @@ -1,228 +1,229 @@ vim9script -if exists('s:is_loaded') +if exists('s:is_loaded') || v:version < 901 || &cp finish endif + var is_loaded: bool = true -############################################################################# -# logger.vim - terse, clearly-prefixed messaging for Vim9 plugins. -# -# Handles: normal messages (echom), warnings, errors, and caught exceptions. -# Prefixed as: Plugin - script.vim - Method: message -# Script and method ommitted when not known/applicable +############################################################################## +# Plugin_Name: Logger +# Shows and logs the info, warning, error, and exception messages of a +# Vim9 plugin. Each message starts with the plugin, script, and method +# name when known: MyPlugin - foo.vim - Bar: text # -# Usage: per script: +# Usage in each script: # import 'Logger/logger.vim' as Log # var log = Log.Logger.new('MyPlugin', expand(':t')) # -# def DoNotPushBigRedButton() -# log.Info('Do not press the big red button.') -# log.Warn('If you press the big red button, you die, she dies, everybody -# dies!') -# log.Error('The idiot pressed the button.') +# def Bar() +# log.Info('Started.') +# log.Warn('No config found. Using defaults.') +# log.Error('Cannot write the file.') # try -# PushBigRedButton() +# Risky() # catch # log.Exception() # endtry +# log.Try(() => Risky()) +# throw log.Fmt('No match.') # enddef # -# Short version: (no finally/continue included) -# def DoNotPushBigRedButton() -# log.Try(() => PushBigRedButton()) -# enddef -# -# Messages: -# :LoggerMessages - opens a scratch window with recent log history -# across all Logger instances/plugins. -# Messages emitted before VimEnter are re-shown after VimEnter, since Vim's -############################################################################# - - -# module shared state -var history: list> = [] -var historyMax: number = 200 -var pending: list> = [] -var hooksInstalled: bool = false - -def EnsureHooks(): void - if hooksInstalled - return - endif - hooksInstalled = true - augroup LoggerFlushPending - autocmd! - autocmd VimEnter * FlushPending() - augroup END -enddef - -def EchoMsg(kind: string, text: string) - if kind ==# 'error' || kind ==# 'exception' - echohl ErrorMsg - elseif kind ==# 'warn' - echohl WarningMsg - endif - echom text - echohl None -enddef - -def Record(kind: string, text: string) - history->add({kind: kind, text: text, time: strftime('%H:%M:%S')}) - if len(history) > historyMax - history->remove(0) - endif - - var entered: bool = !exists('v:vim_did_enter') || v:vim_did_enter - if !entered - pending->add({kind: kind, text: text}) - endif -enddef - -def FlushPending() - if empty(pending) - return - endif - for item in pending - EchoMsg(item.kind, item.text) - endfor - pending = [] -enddef - -def ShowHistory(): void - if empty(history) - echom 'Logger: no messages recorded yet.' - return - endif - new - setlocal buftype=nofile bufhidden=wipe noswapfile nobuflisted - setlocal filetype=log - setline(1, history->mapnew((_, h) => printf('[%s] %s', h.time, h.text))) - setlocal nomodifiable -enddef - -command! -bar LoggerMessages ShowHistory() - - -####################################################################### -# Class: Logger -####################################################################### +# Options are global variables, read on each message. See doc/logger.txt. +# License: GNU GPL 3.0 +############################################################################## + +import './config.vim' as C +import './logfile.vim' as L +import './messages.vim' as M + +# A level no user can set. Marks a Logger made without a level argument. +const LEVEL_UNSET: number = -2 +const SEVERITY: dict = {error: 0, exception: 0, warn: 1, info: 2} +const TAG: dict = { + info: '', + warn: '[WARN] ', + error: '[ERROR] ', + exception: '[EXCEPTION] ', +} +const LABEL: dict = { + info: 'INFO', + warn: 'WARN', + error: 'ERROR', + exception: 'EXCEPTION', +} + +# CLASS: Message output for one script of one plugin. export class Logger var plugin: string - var script = '' - var level = 2 # -1=silent 0=error 1=warn 2=info(default) + var script: string = '' + var level: number = LEVEL_UNSET + var config: C.Config + var file: L.LogFile + var fileFailed: bool = false def new(this.plugin = v:none, this.script = v:none, this.level = v:none) - EnsureHooks() + this.config = C.Config.new(this.plugin) + this.file = L.LogFile.new(this.config.key) enddef - # Builds "Plugin - script - method: " (omitting parts that are unset). + # METHOD: Return the message prefix. Omit the parts that are empty. def Prefix(method: string): string - var parts: list = [this.plugin] - if this.script != '' - parts->add(this.script) + var parts: list = [this.plugin, this.script, method] + ->filter((_, p) => p != '') + return $"{parts->join(' - ')}: " + enddef + + # METHOD: Return the name of the function skip frames up the call stack. + # Return an empty string for script level code, lambdas, and the + # command line. The default skips this method and the public method + # that calls it. + def CallerName(skip: number = 2): string + var frames: list = expand('')->split('\.\.') + var idx: number = len(frames) - 1 - skip + if idx < 0 + return '' endif - var text = parts->join(' - ') - if method != '' - text ..= ' - ' .. method + var name: string = frames[idx] + ->substitute('\[\d\+\]$', '', '') + ->substitute('^function ', '', '') + ->substitute('^\d\+_', '', '') + ->substitute('^.*#', '', '') + return name =~# '^\h[[:alnum:]_.]*$' ? name : '' + enddef + + # METHOD: Show an info message, as echom does. + def Info(msg: string, method: string = ''): void + var m: string = method != '' ? method : this.CallerName() + this._Emit('info', this.Prefix(m), msg, '', true) + enddef + + # METHOD: Show a warning message. + def Warn(msg: string, method: string = ''): void + var m: string = method != '' ? method : this.CallerName() + this._Emit('warn', this.Prefix(m), msg, '', true) + enddef + + # METHOD: Show an error message. + def Error(msg: string, method: string = ''): void + var m: string = method != '' ? method : this.CallerName() + this._Emit('error', this.Prefix(m), msg, '', true) + enddef + + # METHOD: Show a caught exception. With no arguments, use v:exception + # and v:throwpoint. Fmt already recorded and logged a formatted text, + # so only show it. + def Exception( + exception: string = v:exception, + throwpoint: string = v:throwpoint, + method: string = ''): void + if stridx(exception, TAG.exception) >= 0 + if this._Shown('exception') + var lines: list = [exception] + if throwpoint != '' + lines->add($' at {throwpoint}') + endif + M.Echo('exception', lines) + endif + return endif - return text .. ': ' + var m: string = method != '' ? method : this.CallerName() + this._Emit('exception', this.Prefix(m), exception, throwpoint, true) enddef - # Best-effort caller-function detection via the (Vim 9-only) full call - # stack. Gracefully returns '' if unavailable/unparseable - def CallerName(skip = 2): string + # METHOD: Return a formatted exception text to throw. Record and log it + # now, because Vim shows an uncaught exception without Logger. + def Fmt(msg: string, method: string = ''): string + var m: string = method != '' ? method : this.CallerName() + var prefix: string = this.Prefix(m) + this._Emit('exception', prefix, msg, '', false) + return $'{prefix}{TAG.exception}{msg}' + enddef + + # METHOD: Call Fn and return its result. On an exception, show it and + # return v:none. + def Try(Fn: func(): any, method: string = ''): any + var m: string = method != '' ? method : this.CallerName() try - var stack = expand('') - if stack ==# '' || stack ==# '' - return '' - endif - var frames = stack->split('\.\.') - var idx = len(frames) - 1 - skip - if idx < 0 || idx >= len(frames) - return '' - endif - var name = substitute(frames[idx], '\[\d\+\]$', '', '') - name = substitute(name, '^.*[/\\]', '', '') - if name ==# '' || name =~# '\.vim$' - return '' # top-level script code, not inside a function - endif - return name + return Fn() catch - return '' + this.Exception(v:exception, v:throwpoint, m) + return v:none endtry enddef - # echom - def Info(msg: string, method = '') - if this.level < 2 - return + # METHOD: Return the echo level. A plugin option overrides the level + # argument of new, which overrides the global option. + def _EchoLevel(): number + if !this.config.HasOwn('level') && this.level != LEVEL_UNSET + return this.level endif - var m = method != '' ? method : this.CallerName() - var text = this.Prefix(m) .. msg - Record('info', text) - EchoMsg('info', text) + return this.config.GetNumber('level', 2) enddef - def Warn(msg: string, method = '') - if this.level < 1 - return - endif - var m = method != '' ? method : this.CallerName() - var text = this.Prefix(m) .. '[WARN] ' .. msg - Record('warn', text) - EchoMsg('warn', text) + def _Shown(kind: string): bool + return this.config.GetBool('echo', true) + && SEVERITY[kind] <= this._EchoLevel() enddef - def Error(msg: string, method = '') - if this.level < 0 - return - endif - var m = method != '' ? method : this.CallerName() - var text = this.Prefix(m) .. '[ERROR] ' .. msg - Record('error', text) - EchoMsg('error', text) + def _Logged(kind: string): bool + return this.config.GetBool('log_file', false) + && SEVERITY[kind] <= this.config.GetNumber('log_level', 2) enddef - # Call with no args from inside a catch block to log v:exception / - # v:throwpoint automatically, or pass your own text explicitly. - def Exception(exception = v:exception, throwpoint = v:throwpoint, method = '') - if this.level < 0 + # METHOD: Record, echo, and log one message, as the options allow. With + # echo false, record and log only. + def _Emit( + kind: string, + prefix: string, + msg: string, + throwpoint: string, + echo: bool): void + var shown: bool = this._Shown(kind) + var logged: bool = this._Logged(kind) + if !shown && !logged return endif - var text: string - if exception =~# '\[EXCEPTION\] ' - text = exception - else - var m = method != '' ? method : this.CallerName() - text = this.Prefix(m) .. '[EXCEPTION] ' .. exception - endif - Record('exception', text) - EchoMsg('exception', text) + var lines: list = [$'{prefix}{TAG[kind]}{msg}'] if throwpoint != '' - echom ' at ' .. throwpoint + lines->add($' at {throwpoint}') + endif + M.Record(kind, lines) + if shown && echo + M.Echo(kind, lines) + endif + if logged + this._WriteFile(kind, prefix, msg, throwpoint) endif enddef - - # Use with a bare `throw log.Fmt('message') - def Fmt(msg: string, method = ''): string - var m = method != '' ? method : this.CallerName() - var text = this.Prefix(m) .. '[EXCEPTION] ' .. msg - Record('exception', text) - return text - enddef - - - # Wrap a zero-arg Funcref in try/catch, auto-logging any exception. - # Returns the Funcref's result, or v:none if it threw. - def Try(Fn: func(): any, method = ''): any - var m = method != '' ? method : this.CallerName() + # METHOD: Append one message to the log file. After one failure, stop + # for this session, so that each message does not repeat the warning. + def _WriteFile( + kind: string, + prefix: string, + msg: string, + throwpoint: string): void + if this.fileFailed + return + endif + var stamp: string = strftime('%Y-%m-%d %H:%M:%S') + var lines: list = [$'{stamp} {LABEL[kind]} {prefix}{msg}'] + if throwpoint != '' + lines->add($' at {throwpoint}') + endif try - return Fn() + this.file.Write( + lines, + this.config.GetString('log_dir', '~/.Logger', false), + this.config.GetNumber('log_retention', 10)) catch - this.Exception(v:exception, v:throwpoint, m) - return v:none + this.fileFailed = true + var warning: list = [ + $'Logger: cannot write the log file of {this.plugin}: {v:exception}' + ] + M.Record('warn', warning) + M.Echo('warn', warning) endtry enddef endclass diff --git a/import/Logger/messages.vim b/import/Logger/messages.vim new file mode 100644 index 0000000..1d3d3bc --- /dev/null +++ b/import/Logger/messages.vim @@ -0,0 +1,99 @@ +vim9script + +if exists('s:is_loaded') || v:version < 901 || &cp + finish +endif + +var is_loaded: bool = true + +############################################################################## +# Plugin_Name: Logger +# Keeps the shared message history for all Logger instances, echoes +# messages with highlight, and holds messages sent before VimEnter until +# Vim has started, because startup redraws hide them. +# License: GNU GPL 3.0 +############################################################################## + +var history: list> = [] +var pending: list> = [] + +const HISTORY_MAX: number = 200 +const HIGHLIGHT: dict = { + info: 'None', + warn: 'WarningMsg', + error: 'ErrorMsg', + exception: 'ErrorMsg', +} + +# FUNCTION: Add one message to the history. Drop the oldest entry when the +# history is full. +export def Record(kind: string, lines: list): void + history->add({time: strftime('%H:%M:%S'), kind: kind, lines: lines}) + if len(history) > HISTORY_MAX + history->remove(0) + endif +enddef + +# FUNCTION: Echo one message. Before VimEnter, queue it for Flush. +export def Echo(kind: string, lines: list): void + if !v:vim_did_enter + pending->add({kind: kind, lines: lines}) + return + endif + EchoNow(kind, lines) +enddef + +# FUNCTION: Echo all queued messages in the order they were sent. +export def Flush(): void + for p in pending + EchoNow(p.kind, p.lines) + endfor + pending = [] +enddef + +# FUNCTION: Return a copy of the history, oldest first. +export def History(): list> + return deepcopy(history) +enddef + +# FUNCTION: Return a copy of the messages that wait for VimEnter. +export def Pending(): list> + return deepcopy(pending) +enddef + +# FUNCTION: Empty the history and the queue. +export def Clear(): void + history = [] + pending = [] +enddef + +# FUNCTION: Show the history in a scratch window. +export def Show(): void + if empty(history) + echom 'Logger: no messages recorded.' + return + endif + var lines: list = [] + for h in history + lines->add($'[{h.time}] {h.lines[0]}') + lines->extend(h.lines[1 : ]->mapnew((_, l) => $' {l}')) + endfor + new + setlocal buftype=nofile bufhidden=wipe noswapfile nobuflisted + setlocal filetype=log + setline(1, lines) + setlocal nomodifiable +enddef + +def EchoNow(kind: string, lines: list): void + execute $'echohl {HIGHLIGHT[kind]}' + for l in lines + echom l + endfor + echohl None +enddef + +augroup LoggerFlushPending + autocmd! + autocmd VimEnter * Flush() +augroup END diff --git a/plugin/logger.vim b/plugin/logger.vim new file mode 100644 index 0000000..c127d85 --- /dev/null +++ b/plugin/logger.vim @@ -0,0 +1,25 @@ +vim9script + +if exists('s:is_loaded') || v:version < 901 || &cp + finish +endif + +var is_loaded: bool = true + +############################################################################## +# Plugin_Name: Logger +# Sets the global option defaults and defines :LoggerMessages. A plugin +# option such as g:logger_foo_echo overrides each global option. +# License: GNU GPL 3.0 +############################################################################## + +import 'Logger/messages.vim' as M + +g:logger_echo = get(g:, 'logger_echo', true) +g:logger_level = get(g:, 'logger_level', 2) +g:logger_log_file = get(g:, 'logger_log_file', false) +g:logger_log_level = get(g:, 'logger_log_level', 2) +g:logger_log_retention = get(g:, 'logger_log_retention', 10) +g:logger_log_dir = get(g:, 'logger_log_dir', '~/.Logger') + +command -bar LoggerMessages M.Show() diff --git a/tests/fixtures.vim b/tests/fixtures.vim new file mode 100644 index 0000000..6ec2327 --- /dev/null +++ b/tests/fixtures.vim @@ -0,0 +1,29 @@ +vim9script + +############################################################################## +# Plugin_Name: Logger +# Shared setup for the tests. +# License: GNU GPL 3.0 +############################################################################## + +import 'Logger/messages.vim' as M + +# FUNCTION: Remove all Logger options and messages, then point the log +# folder at a new temporary path so that no test writes to ~/.Logger. +# Return that path. +export def Reset(): string + for k in keys(g:)->filter((_, n) => n =~# '^logger_') + execute $'unlet g:{k}' + endfor + var dir: string = tempname() + g:logger_log_dir = dir + M.Clear() + return dir +enddef + +# FUNCTION: Return the lines of the only log file in dir. Return an empty +# list when dir has no log file. +export def LogLines(dir: string): list + var files: list = glob($'{dir}/*.log', true, true) + return len(files) == 1 ? readfile(files[0]) : [] +enddef diff --git a/tests/harness.vim b/tests/harness.vim new file mode 100644 index 0000000..b596547 --- /dev/null +++ b/tests/harness.vim @@ -0,0 +1,44 @@ +vim9script + +############################################################################## +# Plugin_Name: Logger +# Runs the RunAll function of each test module, then reports the failures +# that the assert functions put in v:errors. Writes tests/results.txt +# because -es mode shows no messages. +# +# Usage from the root of the repository: +# vim -es -u NONE -N -c 'set rtp+=.' -c 'source tests/harness.vim' \ +# -c 'quit' +# +# The exit code is 0 on success and 1 on any failed assertion. +# License: GNU GPL 3.0 +############################################################################## + +# Without a UTF-8 locale, Vim starts with encoding latin1. +set encoding=utf-8 + +# -u NONE skips plugin files, so load the plugin as Vim startup does. +execute $"source {expand(':h:h')}/plugin/logger.vim" + +import './test_config.vim' as TestConfig +import './test_logfile.vim' as TestLogFile +import './test_logger.vim' as TestLogger +import './test_messages.vim' as TestMessages + +var suites: list = [ + TestConfig.RunAll, + TestLogFile.RunAll, + TestLogger.RunAll, + TestMessages.RunAll, +] + +for RunSuite in suites + RunSuite() +endfor + +if len(v:errors) > 0 + writefile(v:errors + [$'{len(v:errors)} assertion(s) failed'], 'tests/results.txt') + cquit 1 +else + writefile(['All tests passed'], 'tests/results.txt') +endif diff --git a/tests/test_config.vim b/tests/test_config.vim new file mode 100644 index 0000000..2443136 --- /dev/null +++ b/tests/test_config.vim @@ -0,0 +1,63 @@ +vim9script + +############################################################################## +# Plugin_Name: Logger +# Tests config.vim: the plugin key, the lookup order of plugin, global, +# and default values, and the fallback for a value of the wrong type. +# License: GNU GPL 3.0 +############################################################################## + +import 'Logger/config.vim' as C +import './fixtures.vim' as F + +def Test_key_is_lowercase_and_safe(): void + F.Reset() + assert_equal('foo', C.Config.new('Foo').key) + assert_equal('my_plugin_2', C.Config.new('My-Plugin 2').key) +enddef + +def Test_plugin_value_overrides_global_and_default(): void + F.Reset() + var config = C.Config.new('Foo') + assert_equal(10, config.GetNumber('log_retention', 10)) + g:logger_log_retention = 5 + assert_equal(5, config.GetNumber('log_retention', 10)) + g:logger_foo_log_retention = 3 + assert_true(config.HasOwn('log_retention')) + assert_equal(3, config.GetNumber('log_retention', 10)) + assert_equal(5, C.Config.new('Bar').GetNumber('log_retention', 10)) + F.Reset() +enddef + +def Test_global_only_skips_plugin_value(): void + F.Reset() + g:logger_log_dir = '/global' + g:logger_foo_log_dir = '/plugin' + var config = C.Config.new('Foo') + assert_equal('/plugin', config.GetString('log_dir', '/default')) + assert_equal('/global', config.GetString('log_dir', '/default', false)) + F.Reset() +enddef + +def Test_wrong_type_returns_default(): void + F.Reset() + var config = C.Config.new('Foo') + g:logger_foo_level = 'high' + assert_equal(2, config.GetNumber('level', 2)) + g:logger_foo_level = true + assert_equal(1, config.GetNumber('level', 2)) + g:logger_foo_echo = 'yes' + assert_true(config.GetBool('echo', true)) + g:logger_foo_echo = 0 + assert_false(config.GetBool('echo', true)) + g:logger_foo_log_dir = 42 + assert_equal('/default', config.GetString('log_dir', '/default')) + F.Reset() +enddef + +export def RunAll(): void + Test_key_is_lowercase_and_safe() + Test_plugin_value_overrides_global_and_default() + Test_global_only_skips_plugin_value() + Test_wrong_type_returns_default() +enddef diff --git a/tests/test_logfile.vim b/tests/test_logfile.vim new file mode 100644 index 0000000..a84641d --- /dev/null +++ b/tests/test_logfile.vim @@ -0,0 +1,91 @@ +vim9script + +############################################################################## +# Plugin_Name: Logger +# Tests logfile.vim: the session file name, appends, the retention of old +# files, a change of folder, and the error for a folder Vim cannot make. +# License: GNU GPL 3.0 +############################################################################## + +import 'Logger/logfile.vim' as L + +const SESSION_NAME: string = '^foo_\d\{8}_\d\{6}\.log$' + +def Test_creates_folder_and_appends(): void + var dir: string = $'{tempname()}/sub' + var file = L.LogFile.new('foo') + file.Write(['one'], dir, 10) + file.Write(['two', 'three'], dir, 10) + var names: list = readdir(dir) + assert_equal(1, len(names)) + assert_match(SESSION_NAME, names[0]) + assert_equal(['one', 'two', 'three'], readfile($'{dir}/{names[0]}')) + delete(fnamemodify(dir, ':h'), 'rf') +enddef + +def Test_keeps_only_retention_old_files(): void + var dir: string = tempname() + mkdir(dir, 'p') + for d in range(1, 5) + writefile(['old'], $'{dir}/foo_2020010{d}_000000.log') + endfor + writefile(['other plugin'], $'{dir}/foo_bar_20200101_000000.log') + writefile(['not a log'], $'{dir}/notes.txt') + var file = L.LogFile.new('foo') + file.Write(['new'], dir, 2) + var names: list = readdir(dir) + assert_equal(5, len(names)) + assert_equal(-1, index(names, 'foo_20200101_000000.log')) + assert_equal(-1, index(names, 'foo_20200103_000000.log')) + assert_notequal(-1, index(names, 'foo_20200104_000000.log')) + assert_notequal(-1, index(names, 'foo_20200105_000000.log')) + assert_notequal(-1, index(names, 'foo_bar_20200101_000000.log')) + assert_notequal(-1, index(names, 'notes.txt')) + assert_equal(['new'], readfile(file.path)) + delete(dir, 'rf') +enddef + +def Test_retention_zero_keeps_all(): void + var dir: string = tempname() + mkdir(dir, 'p') + for d in range(1, 3) + writefile(['old'], $'{dir}/foo_2020010{d}_000000.log') + endfor + L.LogFile.new('foo').Write(['new'], dir, 0) + assert_equal(4, len(readdir(dir))) + delete(dir, 'rf') +enddef + +def Test_new_folder_starts_new_file(): void + var first: string = tempname() + var second: string = tempname() + var file = L.LogFile.new('foo') + file.Write(['a'], first, 10) + file.Write(['b'], second, 10) + assert_match($'^{second}', file.path) + assert_equal(1, len(readdir(first))) + assert_equal(1, len(readdir(second))) + delete(first, 'rf') + delete(second, 'rf') +enddef + +def Test_throws_when_folder_is_a_file(): void + var blocker: string = tempname() + writefile(['file'], blocker) + var failed: bool = false + try + L.LogFile.new('foo').Write(['a'], $'{blocker}/logs', 10) + catch + failed = true + endtry + assert_true(failed) + delete(blocker) +enddef + +export def RunAll(): void + Test_creates_folder_and_appends() + Test_keeps_only_retention_old_files() + Test_retention_zero_keeps_all() + Test_new_folder_starts_new_file() + Test_throws_when_folder_is_a_file() +enddef diff --git a/tests/test_logger.vim b/tests/test_logger.vim new file mode 100644 index 0000000..b4f180a --- /dev/null +++ b/tests/test_logger.vim @@ -0,0 +1,232 @@ +vim9script + +############################################################################## +# Plugin_Name: Logger +# Tests logger.vim: prefixes, caller names, tags, echo and file levels, +# the order of option lookup, file lines, exceptions, Fmt, and Try. +# +# In -es mode v:vim_did_enter stays false, so each echoed message waits +# in the queue. The tests use the queue to see what Logger echoes. +# License: GNU GPL 3.0 +############################################################################## + +import 'Logger/logger.vim' as Log +import 'Logger/messages.vim' as M +import './fixtures.vim' as F +import './thrower.vim' as T + +const STAMP: string = '^\d\{4}-\d\d-\d\d \d\d:\d\d:\d\d ' + +def FirstLines(entries: list>): list + return entries->mapnew((_, e) => e.lines[0]) +enddef + +class Probe + var log: Log.Logger + def new(this.log) + enddef + def Speak() + this.log.Info('from a method') + enddef +endclass + +def Test_prefix_omits_empty_parts(): void + assert_equal('P - s.vim - M: ', Log.Logger.new('P', 's.vim').Prefix('M')) + assert_equal('P - s.vim: ', Log.Logger.new('P', 's.vim').Prefix('')) + assert_equal('P: ', Log.Logger.new('P').Prefix('')) +enddef + +def Test_caller_name_of_function_and_method(): void + F.Reset() + var log = Log.Logger.new('P', 't.vim') + log.Info('from a function') + Probe.new(log).Speak() + log.Info('named', 'Custom') + assert_equal([ + 'P - t.vim - Test_caller_name_of_function_and_method: from a function', + 'P - t.vim - Probe.Speak: from a method', + 'P - t.vim - Custom: named', + ], FirstLines(M.History())) +enddef + +def Test_tags(): void + F.Reset() + var log = Log.Logger.new('P', 't.vim') + log.Warn('w', 'X') + log.Error('e', 'X') + assert_equal(['P - t.vim - X: [WARN] w', 'P - t.vim - X: [ERROR] e'], + FirstLines(M.History())) +enddef + +def Test_level_limits_echo(): void + F.Reset() + g:logger_level = 1 + var log = Log.Logger.new('P') + log.Info('i', 'X') + log.Warn('w', 'X') + assert_equal(['P - X: [WARN] w'], FirstLines(M.Pending())) + g:logger_level = -1 + M.Clear() + log.Error('e', 'X') + assert_equal([], M.Pending()) + F.Reset() +enddef + +def Test_level_lookup_order(): void + F.Reset() + g:logger_level = 2 + var log = Log.Logger.new('P', '', 0) + log.Warn('hidden by the new argument', 'X') + assert_equal([], M.Pending()) + g:logger_p_level = 1 + log.Warn('shown by the plugin option', 'X') + assert_equal(1, len(M.Pending())) + F.Reset() +enddef + +def Test_echo_off_for_one_plugin(): void + F.Reset() + g:logger_a_echo = false + Log.Logger.new('A').Info('quiet', 'X') + Log.Logger.new('B').Info('loud', 'X') + assert_equal(['B - X: loud'], FirstLines(M.Pending())) + F.Reset() +enddef + +def Test_file_is_off_by_default(): void + var dir: string = F.Reset() + Log.Logger.new('P').Error('e', 'X') + assert_false(isdirectory(dir)) +enddef + +def Test_file_line_format(): void + var dir: string = F.Reset() + g:logger_log_file = true + Log.Logger.new('P', 't.vim').Warn('text', 'X') + var lines: list = F.LogLines(dir) + assert_equal(1, len(lines)) + assert_match($'{STAMP}WARN P - t.vim - X: text$', lines[0]) + delete(dir, 'rf') + F.Reset() +enddef + +def Test_log_level_limits_file(): void + var dir: string = F.Reset() + g:logger_log_file = true + g:logger_log_level = 0 + var log = Log.Logger.new('P') + log.Warn('w', 'X') + log.Error('e', 'X') + var lines: list = F.LogLines(dir) + assert_equal(1, len(lines)) + assert_match('ERROR P - X: e$', lines[0]) + delete(dir, 'rf') + F.Reset() +enddef + +def Test_file_without_echo(): void + var dir: string = F.Reset() + g:logger_p_log_file = true + g:logger_p_echo = false + Log.Logger.new('P').Info('only in the file', 'X') + assert_equal([], M.Pending()) + assert_equal(1, len(M.History())) + assert_equal(1, len(F.LogLines(dir))) + delete(dir, 'rf') + F.Reset() +enddef + +def Test_exception_has_throwpoint(): void + var dir: string = F.Reset() + g:logger_log_file = true + var log = Log.Logger.new('P') + try + throw 'boom' + catch + log.Exception() + endtry + var lines: list = M.History()[0].lines + assert_equal('P - Test_exception_has_throwpoint: [EXCEPTION] boom', lines[0]) + assert_match('^ at .*Test_exception_has_throwpoint', lines[1]) + var logged: list = F.LogLines(dir) + assert_equal(2, len(logged)) + assert_match('EXCEPTION P - Test_exception_has_throwpoint: boom$', logged[0]) + delete(dir, 'rf') + F.Reset() +enddef + +def Test_fmt_is_recorded_and_logged_once(): void + var dir: string = F.Reset() + g:logger_log_file = true + var log = Log.Logger.new('P') + try + throw log.Fmt('bad', 'X') + catch + log.Exception() + endtry + assert_equal(['P - X: [EXCEPTION] bad'], FirstLines(M.History())) + assert_equal(['P - X: [EXCEPTION] bad'], FirstLines(M.Pending())) + assert_equal(1, len(F.LogLines(dir))) + delete(dir, 'rf') + F.Reset() +enddef + +def Test_fmt_is_caught_in_another_script(): void + F.Reset() + var caught: string = '' + assert_equal('this', T.GetThat('this')) + try + T.GetThat('that') + catch + caught = v:exception + endtry + assert_equal('Test - thrower.vim - GetThat: [EXCEPTION] dangit', caught) + F.Reset() +enddef + +def Test_try_returns_result_or_none(): void + F.Reset() + var log = Log.Logger.new('P') + assert_equal(7, log.Try(() => 7)) + var Fails = (): any => { + throw 'nope' + } + assert_equal(v:none, log.Try(Fails, 'X')) + assert_equal(['P - X: [EXCEPTION] nope'], FirstLines(M.History())) + F.Reset() +enddef + +def Test_write_failure_warns_once(): void + F.Reset() + var blocker: string = tempname() + writefile(['file'], blocker) + g:logger_log_dir = $'{blocker}/logs' + g:logger_log_file = true + var log = Log.Logger.new('P') + log.Info('first', 'X') + log.Info('second', 'X') + var warnings: list> = M.History() + ->filter((_, e) => e.kind ==# 'warn') + assert_equal(1, len(warnings)) + assert_match('^Logger: cannot write the log file of P: ', warnings[0].lines[0]) + delete(blocker) + F.Reset() +enddef + +export def RunAll(): void + Test_prefix_omits_empty_parts() + Test_caller_name_of_function_and_method() + Test_tags() + Test_level_limits_echo() + Test_level_lookup_order() + Test_echo_off_for_one_plugin() + Test_file_is_off_by_default() + Test_file_line_format() + Test_log_level_limits_file() + Test_file_without_echo() + Test_exception_has_throwpoint() + Test_fmt_is_recorded_and_logged_once() + Test_fmt_is_caught_in_another_script() + Test_try_returns_result_or_none() + Test_write_failure_warns_once() +enddef diff --git a/tests/test_messages.vim b/tests/test_messages.vim new file mode 100644 index 0000000..610e573 --- /dev/null +++ b/tests/test_messages.vim @@ -0,0 +1,58 @@ +vim9script + +############################################################################## +# Plugin_Name: Logger +# Tests messages.vim: the history limit, the queue before VimEnter, and +# the :LoggerMessages window. +# License: GNU GPL 3.0 +############################################################################## + +import 'Logger/messages.vim' as M +import './fixtures.vim' as F + +def Test_history_keeps_last_200(): void + F.Reset() + for i in range(250) + M.Record('info', [$'message {i}']) + endfor + var entries: list> = M.History() + assert_equal(200, len(entries)) + assert_equal(['message 50'], entries[0].lines) + F.Reset() +enddef + +def Test_flush_echoes_queue_in_order(): void + F.Reset() + M.Echo('info', ['queued one']) + M.Echo('warn', ['queued two', ' at here']) + assert_equal(2, len(M.Pending())) + messages clear + M.Flush() + assert_equal([], M.Pending()) + assert_equal(['queued one', 'queued two', ' at here'], + execute('messages')->split("\n")[-3 : ]) + F.Reset() +enddef + +def Test_command_shows_history(): void + F.Reset() + assert_equal(2, exists(':LoggerMessages')) + M.Record('info', ['first']) + M.Record('exception', ['second', ' at here']) + LoggerMessages + var lines: list = getline(1, '$') + assert_equal(3, len(lines)) + assert_match('^\[\d\d:\d\d:\d\d\] first$', lines[0]) + assert_match('^\[\d\d:\d\d:\d\d\] second$', lines[1]) + assert_equal(' at here', lines[2]) + assert_equal('nofile', &buftype) + assert_false(&modifiable) + close + F.Reset() +enddef + +export def RunAll(): void + Test_history_keeps_last_200() + Test_flush_echoes_queue_in_order() + Test_command_shows_history() +enddef diff --git a/tests/thrower.vim b/tests/thrower.vim new file mode 100644 index 0000000..8debabf --- /dev/null +++ b/tests/thrower.vim @@ -0,0 +1,20 @@ +vim9script + +############################################################################## +# Plugin_Name: Logger +# A second script that throws a Fmt exception, for the test that catches +# it in another script. +# License: GNU GPL 3.0 +############################################################################## + +import 'Logger/logger.vim' as Log + +var log = Log.Logger.new('Test', 'thrower.vim') + +# FUNCTION: Return name when it is this. Else throw. +export def GetThat(name: string): string + if name ==# 'this' + return name + endif + throw log.Fmt('dangit') +enddef