pax_global_header 0000666 0000000 0000000 00000000064 15135744745 0014531 g ustar 00root root 0000000 0000000 52 comment=b41b09fc8804e8b52c44559598c0d5ff0d81410c
moor-2.10.3/ 0000775 0000000 0000000 00000000000 15135744745 0012570 5 ustar 00root root 0000000 0000000 moor-2.10.3/.dockerignore 0000664 0000000 0000000 00000000012 15135744745 0015235 0 ustar 00root root 0000000 0000000 /releases
moor-2.10.3/.github/ 0000775 0000000 0000000 00000000000 15135744745 0014130 5 ustar 00root root 0000000 0000000 moor-2.10.3/.github/workflows/ 0000775 0000000 0000000 00000000000 15135744745 0016165 5 ustar 00root root 0000000 0000000 moor-2.10.3/.github/workflows/deployment.yml 0000664 0000000 0000000 00000000456 15135744745 0021075 0 ustar 00root root 0000000 0000000 name: Continuous Delivery
on:
release:
types: [released]
jobs:
homebrew:
runs-on: ubuntu-latest
steps:
- name: Bump Homebrew formula
uses: dawidd6/action-homebrew-bump-formula@v7
with:
token: ${{secrets.JOHAN_GITHUB_API_TOKEN}}
formula: moor
moor-2.10.3/.github/workflows/linux-ci.yml 0000664 0000000 0000000 00000001454 15135744745 0020444 0 ustar 00root root 0000000 0000000 name: Linux CI
on:
push:
branches: [master]
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
# golangci-lint is required by test.sh. Latest version here if you want
# to bump it, version number is at the end of the "curl | sh"
# commandline below:
# https://github.com/golangci/golangci-lint/releases/latest
- name: Install golangci-lint
run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b "$(go env GOPATH)"/bin v2.1.2
- run: go build ./...
- run: ./test.sh
- run: GOARCH=386 ./test.sh
moor-2.10.3/.github/workflows/windows-ci.yml 0000664 0000000 0000000 00000000607 15135744745 0020776 0 ustar 00root root 0000000 0000000 name: Windows CI
on:
push:
branches: [master]
pull_request:
jobs:
validate:
runs-on: windows-latest
steps:
- name: Check out repository code
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
- run: go build -race ./...
- run: go test -race -timeout 60s ./...
moor-2.10.3/.gitignore 0000664 0000000 0000000 00000000200 15135744745 0014550 0 ustar 00root root 0000000 0000000 /moor
/moor.exe
/releases/moor-*
# For historical reasons, this old name had to be changed to get into Debian
/releases/moar-*
moor-2.10.3/.golangci.yaml 0000664 0000000 0000000 00000000547 15135744745 0015323 0 ustar 00root root 0000000 0000000 version: "2"
linters:
enable:
- revive
- usestdlibvars
settings:
revive:
rules:
- name: "exported"
disabled: true
staticcheck:
checks:
- all
- -QF1003
- -ST1000
- -ST1003
- -ST1005
- -ST1020
- -ST1021
- -ST1022
formatters:
enable:
- gofmt
moor-2.10.3/.whitesource 0000664 0000000 0000000 00000000211 15135744745 0015124 0 ustar 00root root 0000000 0000000 {
"generalSettings": {
"shouldScanRepo": true
},
"checkRunSettings": {
"vulnerableCheckRunConclusionLevel": "failure"
}
} moor-2.10.3/Dockerfile-test-386 0000664 0000000 0000000 00000000277 15135744745 0016063 0 ustar 00root root 0000000 0000000 # Run the tests in 32 bit mode:
# docker build . -f Dockerfile-test-386
FROM golang:1.20
WORKDIR /moor
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN GOARCH=386 go test -v ./...
moor-2.10.3/LICENSE 0000664 0000000 0000000 00000002762 15135744745 0013604 0 ustar 00root root 0000000 0000000 Copyright (c) 2013, johan.walles@gmail.com
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
The views and conclusions contained in the software and documentation are those
of the authors and should not be interpreted as representing official policies,
either expressed or implied, of the FreeBSD Project.
moor-2.10.3/MOUSE.md 0000664 0000000 0000000 00000012540 15135744745 0014004 0 ustar 00root root 0000000 0000000 # Mouse Scrolling vs Selecting and Copying
NOTE: `moor` now adapts to your terminal's capabilities in a lot of cases (look
for `terminalHasArrowKeysEmulation()`). But if mouse scrolling and / or copying
doesn't work, read on!
`moor` supports two mouse modes (using the `--mousemode` parameter):
- `scroll` makes `moor` process mouse events from your terminal, thus enabling mouse scrolling work,
but disabling the ability to select text with mouse in the usual way. Selecting text will require using your terminal's capability to bypass mouse protocol.
Most terminals support this capability, see [Selection workarounds for `scroll` mode](#mouse-selection-workarounds-for-scroll-mode) for details.
- `select` makes `moor` not process mouse events. This makes selecting and copying text work, but scrolling might not be possible, depending on your terminal and its configuration.
- `auto` uses `select` on terminals where we know it won't break scrolling, and
`scroll` on all others. [The white list lives in the
`terminalHasArrowKeysEmulation()` function in
`screen.go`](https://github.com/walles/moor/blob/master/twin/screen.go).
The reason these tradeoffs exist is that if `moor` requests mouse events from the terminal,
it should process _all_ mouse events, including attempts to select text. This is the case with every console application.
However, some terminals can send "fake" arrow key presses to applications which _do not_ request processing mouse events.
This means that on those terminals, you will be better off using `--mousemode select` option, given that you also have this feature enabled (it's usually on by default).
With this setup, both scrolling and text selecting in the usual way will work.
To check whether this could work, simply run `moor` with option `--mousemode select` and see if scrolling still works.
## Mouse Selection Workarounds for `scroll` Mode
Most terminals implement a way to suppress mouse events capturing by applications, thus allowing you to select text even in
those applications which make use of the mouse. Usually this involves selecting with Shift being held. Often the
modifier key is configurable. Some other terminals allow setting options for specific types of mouse events to be reported.
While the table below attempts to list the default behaviours of some common terminals, you should consult
documentation of the one you're using to get detailed up-to-date information.
If your favorite terminal is missing, feel free to add it.
> :warning: With some of these, if you made incorrect selection you can cancel it either with an Escape key press or with a mouse
> click on text area. You will probably need to still hold the modifier key for this, as hitting Escape without it will likely exit `moor`.
| Terminal | Solution |
| -------- | -------- |
| Contour | [Use Shift](https://github.com/contour-terminal/contour/blob/cf434eaae4b428228413039624231ad0a4e6839b/docs/configuration/advanced/mouse.md) when selecting with mouse.
*Cred to @postsolar for this tip.* |
| Foot | [Use Shift](https://codeberg.org/dnkl/foot/wiki#i-can-t-use-the-mouse-to-select-text) when selecting with mouse.
*Cred to @postsolar for this tip.* |
| Terminal on macOS | On a laptop: Hold down the fn key when selecting with mouse. |
# `less`' screen initialization sequence
Recorded using [iTerm's _Automatically log session input to files_ feature](https://iterm2.com/documentation-preferences-profiles-session.html).
`less` is version 487 that comes with macOS 11.3 Big Sur.
All linebreaks are mine, added for readability. The `^M`s are not.
```
less /etc/passwd
^G[30m(B[m^M
[?1049h
[?1h
=^M
##
```
# `moor`'s screen initialization sequence
```
moor /etc/passwd /Users/johan/src/moor
^G[30m(B[m^M
[?1049h
[?1006;1000h
[?25l
[1;1H
[m[2m 1 [22m##
```
# Analysis of `less`
The line starting with `^G` is probably from from [`fish`](https://fishshell.com/) since it's the same for both `less` and `moor`.
`[?1049h` switches to the Alternate Screen Buffer, [search here for `1 0 4 9`](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer) for info.
Then `less` does `[?1h`, which apparently is [DECCKM Cursor Keys Mode, send ESC O A for cursor up](https://www.real-world-systems.com/docs/ANSIcode.html), followed by `=`, meaning [DECKPAM - Set keypad to applications mode (ESCape instead of digits)](https://www.real-world-systems.com/docs/ANSIcode.html).
**NOTE** that this means that `less` version 487 that comes with macOS 11.3 Big Sur doesn't even try to enable any mouse reporting, but relies on the terminal to convert scroll wheel events into arrow keypresses.
# Analysis of `moor`
Same as `less` up until the Alternate Screen Buffer is enabled.
`[?1006;1000h` enables [SGR Mouse Mode and the X11 xterm mouse protocol (search for `1 0 0 0`)](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html).
`[?25l` [hides the cursor](https://invisible-island.net/xterm/ctlseqs/ctlseqs.html). **NOTE** Maybe we don't need this? It might be implicit when we enable the Alternate Screen Buffer.
`[1;1H` [moves the cursor to the top left corner]().
Then it's the first line with its line number in faint type.
moor-2.10.3/README.md 0000664 0000000 0000000 00000023261 15135744745 0014053 0 ustar 00root root 0000000 0000000 Note: :warning: [As of version 2.0.0, `moar` has been renamed to
`moor`](https://github.com/walles/moor/releases/tag/v2.0.0), but is otherwise
the same tool.
[](https://github.com/walles/moor/actions/workflows/linux-ci.yml?query=branch%3Amaster)
[](https://github.com/walles/moor/actions/workflows/windows-ci.yml?query=branch%3Amaster)
Moor is a pager. It reads and displays UTF-8 encoded text from files or pipes.
`moor` is designed to just do the right thing without any configuration:

The intention is that Moor should be trivial to get into if you have previously
been using [Less](http://www.greenwoodsoftware.com/less/). If you come from Less
and find Moor confusing or hard to migrate to, [please report
it](https://github.com/walles/moor/issues)!
Doing the right thing includes:
- **Syntax highlight** source code by default using
[Chroma](https://github.com/alecthomas/chroma)
- **Search is incremental** / find-as-you-type just like in
[Chrome](http://www.google.com/chrome) or
[Emacs](http://www.gnu.org/software/emacs/)
- **Filtering is incremental**: Press & to filter the input
interactively
- Search becomes case sensitive if you add any UPPER CASE characters
to your search terms, just like in Emacs
- [Regexp](http://en.wikipedia.org/wiki/Regular_expression#Basic_concepts)
search if your search string is a valid regexp
- Deduplicated search history persists across `moor` invocations
- **Snappy UI** even on slow / large input by reading input in the background
and using multi-threaded search
- Supports displaying ANSI color coded texts (like the output from
`git diff` [| `riff`](https://github.com/walles/riff) for example)
- Supports UTF-8 input and output
- **Transparent decompression** when viewing [compressed text
files](https://github.com/walles/moor/issues/97#issuecomment-1191415680)
(`.gz`, `.bz2`, `.xz`, `.zst`, `.zstd`) or [streams](https://github.com/walles/moor/issues/261)
- The position in the file is always shown
- Supports **word wrapping** (on actual word boundaries) if requested using
`--wrap` or by pressing w
- [**Follows output** as long as you are on the last line](https://github.com/walles/moor/issues/108#issuecomment-1331743242),
just like `tail -f`
- Renders [terminal
hyperlinks](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda)
properly
- **Mouse Scrolling** works out of the box (but
[look here for tradeoffs](https://github.com/walles/moor/blob/master/MOUSE.md))
[For compatibility reasons](https://github.com/walles/moor/issues/14), `moor`
uses the formats declared in these environment variables if present:
- `LESS_TERMCAP_md`: Man page bold
- `LESS_TERMCAP_us`: Man page underline
- `LESS_TERMCAP_so`: [Status bar and search hits](https://github.com/walles/moor/issues/114)
Setting `LESSSECURE` to `1` will prevent `moor` from launching external programs
or opening new files [as required by `systemctl(1)`][systemctlLessSecure]. In
secure mode, the v command for opening the current file in an editor
is disabled.
For configurability reasons, `moor` reads extra command line options from the
`MOOR` environment variable.
Moor is used as the default pager by:
- [`px` / `ptop`](https://github.com/walles/px), `ps` and `top` for human beings
- [`riff`](https://github.com/walles/riff), a diff filter highlighting which line parts have changed
# Installing
## Using [Homebrew](https://brew.sh/)
**Both macOS and Linux** users can use Homebrew to install. See below for distro
specific instructions.
```sh
brew install moor
```
Then whenever you want to upgrade to the latest release:
```sh
brew upgrade
```
## Using [MacPorts](https://www.macports.org/)
```sh
sudo port install moor
```
More info [here](https://ports.macports.org/port/moor/).
## Using [Gentoo](https://gentoo.org/)
```sh
emerge --ask --verbose sys-apps/moor
```
More info [here](https://packages.gentoo.org/packages/sys-apps/moor).
## Using [Arch Linux](https://archlinux.org/)
```sh
pacman -S moor
```
More info [here](https://archlinux.org/packages/extra/x86_64/moor/).
## Debian / Ubuntu
In progress: https://ftp-master.debian.org/new.html
In the mean time, use Homebrew (see above) or read on for manual install instructions.
## Manual Install
### Using `go`
This will [install
`moor` into `$GOPATH/bin`](<(https://manpages.debian.org/testing/golang-go/go-install.1.en.html)>)
:
```sh
go install github.com/walles/moor/v2/cmd/moor@latest
```
NOTE: If you got here because there is no binary for your platform,
[please consider packaging `moor`](#packaging).
### Downloading binaries
1. Download `moor` for your platform from
1. `chmod a+x moor-*-*-*`
1. `sudo mv moor-*-*-* /usr/local/bin/moor`
And now you can just invoke `moor` from the prompt!
Try `moor --help` to see options.
# Configuring
Do `moor --help` for an up to date list of options.
Environment variable `MOOR` can be used to set default options.
For example:
```bash
export MOOR='--statusbar=bold --no-linenumbers'
```
## Setting `moor` as your default pager
Set it as your default pager by adding...
```bash
export PAGER=/usr/local/bin/moor
```
... to your `.bashrc`.
# Issues
Issues are tracked [here](https://github.com/walles/moor/issues), or
you can send questions to .
# Packaging
If you package `moor`, do include [the man page](moor.1) in your package.
# Embedding `moor` in your app
API Reference: https://pkg.go.dev/github.com/walles/moor/v2/pkg/moor
For a quick start, first fetch your dependency:
```
go get github.com/walles/moor/v2
```
Then, here's how you can use the API:
```go
package main
import (
"github.com/walles/moor/v2/pkg/moor"
)
func main() {
err := moor.PageFromString("Hello, world!", moor.Options{})
if err != nil {
// Handle paging problems
panic(err)
}
}
```
After both `go get` is done and you have calls to `moor` in your code, you may
have to:
```
go mod tidy
```
You can also `PageFromStream()` or `PageFromFile()`.
# Developing
You need the [go tools](https://golang.org/doc/install).
Run tests:
```bash
./test.sh
```
Launch the manual test suite:
```bash
./manual-test.sh
```
To run tests in 32 bit mode, either do `GOARCH=386 ./test.sh` if you're on
Linux, or `docker build . -f Dockerfile-test-386` (tested on macOS).
Run microbenchmarks:
```bash
go test -benchmem -run='^$' -bench=. ./...
```
Profiling `BenchmarkPlainTextSearch()`. Try replacing `-alloc_objects` with
`-alloc_space` or change the `-focus` function:
```bash
go test -memprofilerate=1 -memprofile=profile.out -benchmem -run='^$' -bench '^BenchmarkPlainTextSearch$' ./internal && go tool pprof -alloc_objects -focus findFirstHit -relative_percentages -web profile.out
```
Or to get a CPU profile:
```bash
go test -cpuprofile=profile.out -benchmem -run='^$' -bench '^BenchmarkRenderLines$' ./internal && go tool pprof -focus renderLines -relative_percentages -web profile.out
```
Build + run:
```bash
./moor.sh ...
```
Install (into `/usr/local/bin`) from source:
```bash
./install.sh
```
# Making a new Release
Make sure that [screenshot.png](screenshot.png) matches moor's current UI.
If it doesn't, scale a window to 81x16 characters and make a new one.
Execute `release.sh` and follow instructions.
# TODO
- Enable exiting using ^c (without restoring the screen).
- Enable suspending using ^z, followed by resuming using `fg`.
- Underline the file name in the status bar while viewing. The point is to make
it more obvious where this name ends in case it contains whitespace.
- Retain the search string when pressing / to search a second time.
## Done
- Add `>` markers at the end of lines being cut because they are too long
- Doing moor on an arbitrary binary (like `/bin/ls`) should put all
line-continuation markers at the rightmost column. This really means our
truncation code must work even with things like tabs and various control
characters.
- Make sure search hits are highlighted even when we have to scroll right
to see them
- Change out-of-file visualization to writing `---` after the end of the file
and leaving the rest of the screen blank.
- Exit search on pressing up / down / pageup / pagedown keys and
scroll. I attempted to do that spontaneously, so it's probably a
good idea.
- Remedy all FIXMEs in this README file
- Release the `go` version as the new `moor`, replacing the previous Ruby
implementation
- Add licensing information (same as for the Ruby branch)
- Make sure `git grep` output gets highlighted properly.
- Handle all kinds of line endings.
- Make sure version information is printed if there are warnings.
- Add spinners while file is still loading
- Make `tail -f /dev/null` exit properly, fix
.
- Showing unicode search hits should highlight the correct chars
- [Word wrap text rather than character wrap it](m/linewrapper.go).
- Arrow keys up / down while in line wrapping mode should scroll by screen line,
not by input file line.
- Define 'g' to prompt for a line number to go to.
- Handle search hits to the right of the right screen edge when searching
forwards. Searching forwards now moves first right, then to the left edge
and down.
- Handle search hits to the right of the right screen edge when searching
backwards. Searching backwards should move first left, then up and to the
rightmost hit.
[systemctlLessSecure]: https://github.com/systemd/systemd/blob/ee3cd7890d81744efa9513b739e5ff03c9e7649b/man/common-variables.xml#L193-L194
moor-2.10.3/build.sh 0000775 0000000 0000000 00000002351 15135744745 0014227 0 ustar 00root root 0000000 0000000 #!/bin/bash
set -e -o pipefail
if [ -z ${CI+x} ]; then
# Local build, not in CI, format source
gofmt -s -w .
fi
VERSION="$(git describe --tags --dirty --always)"
BINARY="moor"
if [ -n "${GOOS}${GOARCH}" ]; then
EXE=""
if [ "${GOOS}" = "windows" ]; then
EXE=".exe"
fi
BINARY="releases/${BINARY}-${VERSION}-${GOOS}-${GOARCH}${EXE}"
fi
# Linker flags version number trick below from here:
# https://www.reddit.com/r/golang/comments/4cpi2y/question_where_to_keep_the_version_number_of_a_go/d1kbap7?utm_source=share&utm_medium=web2x
# Linker flags -s and -w strips debug data, but keeps whatever is needed for
# proper panic backtraces, this makes binaries smaller:
# https://boyter.org/posts/trimming-golang-binary-fat/
# This line must be last in the script so that its return code
# propagates properly to its caller
#
# Note that ${RACE} must *not* be quoted, we want it to disappear if empty.
# shellcheck disable=SC2086
go build ${RACE} -trimpath -ldflags="-s -w -X main.versionString=${VERSION}" -o "${BINARY}" ./cmd/moor
# Alternative build line, if you want to attach to the running process in the Go debugger:
# go build -ldflags="-X main.versionString=${VERSION}" -gcflags="all=-N -l" -o "${BINARY}" ./cmd/moor
moor-2.10.3/cmd/ 0000775 0000000 0000000 00000000000 15135744745 0013333 5 ustar 00root root 0000000 0000000 moor-2.10.3/cmd/moor/ 0000775 0000000 0000000 00000000000 15135744745 0014307 5 ustar 00root root 0000000 0000000 moor-2.10.3/cmd/moor/moor.go 0000664 0000000 0000000 00000055035 15135744745 0015622 0 ustar 00root root 0000000 0000000 // Command line launcher for moor
package main
import (
"flag"
"fmt"
"io"
"os"
"runtime"
"runtime/debug"
"strconv"
"strings"
"time"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
log "github.com/sirupsen/logrus"
"golang.org/x/term"
"github.com/walles/moor/v2/internal"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/reader"
"github.com/walles/moor/v2/internal/textstyles"
"github.com/walles/moor/v2/internal/util"
"github.com/walles/moor/v2/twin"
)
var versionString = ""
// Which environment variable should we get our config from?
//
// Prefer MOOR, but if that's not set, look at MOAR as well for backwards
// compatibility reasons.
func moorEnvVarName() string {
moorEnvSet := len(strings.TrimSpace(os.Getenv("MOOR"))) > 0
if moorEnvSet {
return "MOOR"
}
moarEnvSet := len(strings.TrimSpace(os.Getenv("MOAR"))) > 0
if moarEnvSet {
// Legacy, keep for backwards compatibility
return "MOAR"
}
// This is the default
return "MOOR"
}
// printProblemsHeader prints bug reporting information to stderr
func printProblemsHeader() {
fmt.Fprintln(os.Stderr, "Please post the following report at ,")
fmt.Fprintln(os.Stderr, "or e-mail it to johan.walles@gmail.com.")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Version :", getVersion())
fmt.Fprintln(os.Stderr, "LANG :", os.Getenv("LANG"))
fmt.Fprintln(os.Stderr, "TERM :", os.Getenv("TERM"))
fmt.Fprintln(os.Stderr, "EDITOR :", os.Getenv("EDITOR"))
fmt.Fprintln(os.Stderr, "TERM_PROGRAM :", os.Getenv("TERM_PROGRAM"))
fmt.Fprintln(os.Stderr)
lessenv_section := ""
lessTermcapVars := []string{
"LESS_TERMCAP_md",
"LESS_TERMCAP_us",
"LESS_TERMCAP_so",
}
for _, varName := range lessTermcapVars {
value := os.Getenv(varName)
if value == "" {
continue
}
lessenv_section += varName + " : " + strings.ReplaceAll(value, "\x1b", "ESC") + "\n"
}
if lessenv_section != "" {
fmt.Fprintln(os.Stderr, lessenv_section)
}
fmt.Fprintln(os.Stderr, "GOOS :", runtime.GOOS)
fmt.Fprintln(os.Stderr, "GOARCH :", runtime.GOARCH)
fmt.Fprintln(os.Stderr, "Compiler:", runtime.Compiler)
fmt.Fprintln(os.Stderr, "NumCPU :", runtime.NumCPU())
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Stdin is a terminal:", term.IsTerminal(int(os.Stdin.Fd())))
fmt.Fprintln(os.Stderr, "Stdout is a terminal:", term.IsTerminal(int(os.Stdout.Fd())))
fmt.Fprintln(os.Stderr)
if moorEnvVarName() == "MOAR" {
fmt.Fprintln(os.Stderr, "MOAR (legacy):", os.Getenv("MOAR"))
} else {
fmt.Fprintln(os.Stderr, "MOOR:", os.Getenv("MOOR"))
}
fmt.Fprintf(os.Stderr, "Commandline: %#v\n", os.Args)
}
func parseLexerOption(lexerOption string) (chroma.Lexer, error) {
byMimeType := lexers.MatchMimeType(lexerOption)
if byMimeType != nil {
return byMimeType, nil
}
// Use Chroma's built-in fuzzy lexer picker
lexer := lexers.Get(lexerOption)
if lexer != nil {
return lexer, nil
}
return nil, fmt.Errorf(
"Look here for inspiration: https://github.com/alecthomas/chroma/tree/master/lexers/embedded",
)
}
func parseStyleOption(styleOption string) (*chroma.Style, error) {
style, ok := styles.Registry[styleOption]
if !ok {
return &chroma.Style{}, fmt.Errorf(
"Pick a style from here: https://xyproto.github.io/splash/docs/longer/all.html")
}
return style, nil
}
func parseColorsOption(colorsOption string) (twin.ColorCount, error) {
if strings.ToLower(colorsOption) == "auto" {
colorsOption = "16M"
if os.Getenv("COLORTERM") != "truecolor" && strings.Contains(os.Getenv("TERM"), "256") {
// Covers "xterm-256color" as used by the macOS Terminal
colorsOption = "256"
}
}
switch strings.ToUpper(colorsOption) {
case "8":
return twin.ColorCount8, nil
case "16":
return twin.ColorCount16, nil
case "256":
return twin.ColorCount256, nil
case "16M":
return twin.ColorCount24bit, nil
}
var noColor twin.ColorCount
return noColor, fmt.Errorf("Valid counts are 8, 16, 256, 16M or auto")
}
func parseStatusBarStyle(styleOption string) (internal.StatusBarOption, error) {
if styleOption == "inverse" {
return internal.STATUSBAR_STYLE_INVERSE, nil
}
if styleOption == "plain" {
return internal.STATUSBAR_STYLE_PLAIN, nil
}
if styleOption == "bold" {
return internal.STATUSBAR_STYLE_BOLD, nil
}
return 0, fmt.Errorf("Good ones are inverse, plain and bold")
}
func parseUnprintableStyle(styleOption string) (textstyles.UnprintableStyleT, error) {
if styleOption == "highlight" {
return textstyles.UnprintableStyleHighlight, nil
}
if styleOption == "whitespace" {
return textstyles.UnprintableStyleWhitespace, nil
}
return 0, fmt.Errorf("Good ones are highlight or whitespace")
}
func parseScrollHint(scrollHint string) (textstyles.CellWithMetadata, error) {
scrollHint = strings.ReplaceAll(scrollHint, "ESC", "\x1b")
parsedTokens := textstyles.StyledRunesFromString(twin.StyleDefault, scrollHint, nil, 0).StyledRunes
if len(parsedTokens) == 1 {
return parsedTokens[0], nil
}
return textstyles.CellWithMetadata{}, fmt.Errorf("Expected exactly one (optionally highlighted) character. For example: 'ESC[2m…'")
}
func parseShiftAmount(shiftAmount string) (uint, error) {
value, err := strconv.ParseUint(shiftAmount, 10, 32)
if err != nil {
return 0, err
}
if value < 1 {
return 0, fmt.Errorf("Shift amount must be at least 1")
}
// Let's add an upper bound as well if / when requested
return uint(value), nil
}
func parseTabAmount(tabAmount string) (uint, error) {
value, err := strconv.ParseUint(tabAmount, 10, 32)
if err != nil {
return 0, err
}
if value < 1 {
return 0, fmt.Errorf("Tab size must be at least 1")
}
// Let's add an upper bound as well if / when requested
return uint(value), nil
}
func parseMouseMode(mouseMode string) (twin.MouseMode, error) {
switch mouseMode {
case "auto":
return twin.MouseModeAuto, nil
case "select", "mark":
return twin.MouseModeSelect, nil
case "scroll":
return twin.MouseModeScroll, nil
}
return twin.MouseModeAuto, fmt.Errorf("Valid modes are auto, select and scroll")
}
func pumpToStdout(inputFilenames ...string) error {
if len(inputFilenames) > 0 {
stdinDone := false
// If we get both redirected stdin and an input filenames, should only
// copy the files and ignore stdin, because that's how less works.
for _, inputFilename := range inputFilenames {
if inputFilename == "-" && !term.IsTerminal(int(os.Stdin.Fd())) {
// "-" with redirected stdin means "read from stdin"
if stdinDone {
// stdin already drained, don't do it again
continue
}
_, err := io.Copy(os.Stdout, os.Stdin)
if err != nil {
return fmt.Errorf("Failed to copy stdin to stdout: %w", err)
}
stdinDone = true
continue
}
inputFile, _, err := reader.ZOpen(inputFilename)
if err != nil {
return fmt.Errorf("Failed to open %s: %w", inputFilename, err)
}
_, err = io.Copy(os.Stdout, inputFile)
if err != nil {
return fmt.Errorf("Failed to copy %s to stdout: %w", inputFilename, err)
}
}
return nil
}
// No input filenames, pump stdin to stdout
_, err := io.Copy(os.Stdout, os.Stdin)
if err != nil {
return fmt.Errorf("Failed to copy stdin to stdout: %w", err)
}
return nil
}
// Parses an argument like "+123" anywhere on the command line into a one-based
// line number, and returns the remaining args.
//
// Returns nil on no target line number specified.
func getTargetLine(args []string) (*linemetadata.Index, []string) {
for i, arg := range args {
if !strings.HasPrefix(arg, "+") {
continue
}
lineNumber, err := strconv.ParseInt(arg[1:], 10, 32)
if err != nil {
// Let's pretend this is a file name
continue
}
if lineNumber < 0 {
// Pretend this is a file name
continue
}
// Remove the target line number from the args
//
// Ref: https://stackoverflow.com/a/57213476/473672
remainingArgs := make([]string, 0)
remainingArgs = append(remainingArgs, args[:i]...)
remainingArgs = append(remainingArgs, args[i+1:]...)
if lineNumber == 0 {
// Ignore +0 because that's what less does:
// https://github.com/walles/moor/issues/316
return nil, remainingArgs
}
returnMe := linemetadata.IndexFromOneBased(int(lineNumber))
return &returnMe, remainingArgs
}
return nil, args
}
func russiaNotSupported() {
if !strings.HasPrefix(strings.ToLower(os.Getenv("LANG")), "ru_ru") {
// Not russia
return
}
if os.Getenv("CRIMEA") == "Ukraine" {
// It is!
return
}
fmt.Fprintln(os.Stderr, "ERROR: russia not supported (but Russian is!)")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Options for using moor in Russian:")
fmt.Fprintln(os.Stderr, "* Change your language setting to ru_UA.UTF-8")
fmt.Fprintln(os.Stderr, "* Set CRIMEA=Ukraine in your environment")
fmt.Fprintln(os.Stderr, "* russia leaves Ukraine")
os.Exit(1)
}
// For git output and man pages, disable line numbers by default.
//
// Before paging, "man" first checks the terminal width and formats the man page
// to fit that width. Git does that as well for git diff --stat.
//
// Then, if moor adds line numbers, the rightmost part of the page will scroll
// out of view.
//
// So we try to this, and in that case disable line numbers so that the
// rightmost part of the page is visible by default.
//
// See also internal/haveLoadedManPage(), where we try to detect man pages by
// their contents.
func noLineNumbersDefault() bool {
if os.Getenv("MAN_PN") != "" {
// Set by "man" on Ubuntu 22.04.4 when I tested it inside of Docker.
log.Debug("MAN_PN is set, skipping line numbers for man page")
return true
}
if os.Getenv("GIT_EXEC_PATH") != "" {
// Set by "git".
// Neither logs nor diffs are helped by line numbers, turn them off by
// default.
log.Debug("GIT_EXEC_PATH is set, skipping line numbers when paging git output")
return true
}
// Default to not skipping line numbers
return false
}
// Return complete version when built with build.sh or fallback to module version (i.e. "go install")
func getVersion() string {
if versionString != "" {
return versionString
}
info, ok := debug.ReadBuildInfo()
if ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
return info.Main.Version
}
return "Should be set when building, please use build.sh to build"
}
// Can return a nil pager on --help or --version, or if pumping to stdout.
func pagerFromArgs(
args []string,
newScreen func(mouseMode twin.MouseMode, terminalColorCount twin.ColorCount) (twin.Screen, error),
stdinIsRedirected bool,
stdoutIsRedirected bool,
) (
*internal.Pager, twin.Screen, chroma.Style, *chroma.Formatter, bool, error,
) {
// FIXME: If we get a CTRL-C, get terminal back into a useful state before terminating
flagSet := flag.NewFlagSet("",
flag.ContinueOnError, // We want to do our own error handling
)
flagSet.SetOutput(io.Discard) // We want to do our own printing
printVersion := flagSet.Bool("version", false, "Prints the moor version number")
debug := flagSet.Bool("debug", false, "Print debug logs after exiting")
trace := flagSet.Bool("trace", false, "Print trace logs after exiting")
wrap := flagSet.Bool("wrap", false, "Wrap long lines")
follow := flagSet.Bool("follow", false, "Follow piped input just like \"tail -f\"")
styleOption := flagSetFunc(flagSet,
"style", nil,
"Highlighting `style` from https://xyproto.github.io/splash/docs/longer/all.html", parseStyleOption)
lexer := flagSetFunc(flagSet,
"lang", nil,
"File contents, used for highlighting. Mime type or file extension (\"html\"). Default is to guess by filename.", parseLexerOption)
terminalFg := flagSet.Bool("terminal-fg", false, "Use terminal foreground color rather than style foreground for plain text")
noSearchLineHighlight := flagSet.Bool("no-search-line-highlight", false, "Do not highlight the background of lines with search hits")
defaultFormatter, err := parseColorsOption("auto")
if err != nil {
panic(fmt.Errorf("Failed parsing default formatter: %w", err))
}
terminalColorsCount := flagSetFunc(flagSet,
"colors", defaultFormatter, "Highlighting palette size: 8, 16, 256, 16M, auto", parseColorsOption)
noLineNumbers := flagSet.Bool("no-linenumbers", noLineNumbersDefault(), "Hide line numbers on startup, press left arrow key to show")
noStatusBar := flagSet.Bool("no-statusbar", false, "Hide the status bar, toggle with '='")
reFormat := flagSet.Bool("reformat", false, "Reformat some input files (JSON)")
flagSet.Bool("no-reformat", true, "No effect, kept for compatibility. See --reformat")
quitIfOneScreen := flagSet.Bool("quit-if-one-screen", false, "Don't page if contents fits on one screen. Affected by --no-clear-on-exit-margin.")
noClearOnExit := flagSet.Bool("no-clear-on-exit", false, "Retain screen contents when exiting moor")
noClearOnExitMargin := flagSet.Int("no-clear-on-exit-margin", 1,
"Number of lines to leave for your shell prompt, defaults to 1")
statusBarStyle := flagSetFunc(flagSet, "statusbar", internal.STATUSBAR_STYLE_INVERSE,
"Status bar `style`: inverse, plain or bold", parseStatusBarStyle)
unprintableStyle := flagSetFunc(flagSet, "render-unprintable", textstyles.UnprintableStyleHighlight,
"How unprintable characters are rendered: highlight or whitespace", parseUnprintableStyle)
scrollLeftHint := flagSetFunc(flagSet, "scroll-left-hint",
textstyles.CellWithMetadata{Rune: '<', Style: twin.StyleDefault.WithAttr(twin.AttrReverse)},
"Shown when view can scroll left. One character with optional ANSI highlighting.", parseScrollHint)
scrollRightHint := flagSetFunc(flagSet, "scroll-right-hint",
textstyles.CellWithMetadata{Rune: '>', Style: twin.StyleDefault.WithAttr(twin.AttrReverse)},
"Shown when view can scroll right. One character with optional ANSI highlighting.", parseScrollHint)
shift := flagSetFunc(flagSet, "shift", 16, "Horizontal scroll `amount` >=1, defaults to 16", parseShiftAmount)
tabSize := flagSetFunc(flagSet, "tab-size", 8, "Number of spaces per tab stop, defaults to 8", parseTabAmount)
mouseMode := flagSetFunc(
flagSet,
"mousemode",
twin.MouseModeAuto,
"Mouse `mode`: auto, select or scroll: https://github.com/walles/moor/blob/master/MOUSE.md",
parseMouseMode,
)
// Combine flags from environment and from command line
flags := args[1:]
moorEnv := strings.TrimSpace(os.Getenv(moorEnvVarName()))
if len(moorEnv) > 0 {
// FIXME: It would be nice if we could debug log that we're doing this,
// but logging is not yet set up and depends on command line parameters.
flags = append(strings.Fields(moorEnv), flags...)
}
targetLine, remainingArgs := getTargetLine(flags)
err = flagSet.Parse(remainingArgs)
if err == nil {
if *noClearOnExitMargin < 0 {
err = fmt.Errorf("Invalid --no-clear-on-exit-margin %d, must be 0 or higher", *noClearOnExitMargin)
}
}
if err != nil {
if err == flag.ErrHelp {
printUsage(flagSet, *terminalColorsCount)
return nil, nil, chroma.Style{}, nil, false, nil
}
errorText := err.Error()
if strings.HasPrefix(errorText, "invalid value") {
errorText = strings.Replace(errorText, ": ", "\n\n", 1)
}
boldErrorMessage := "\x1b[1m" + errorText + "\x1b[m"
fmt.Fprintln(os.Stderr, "ERROR:", boldErrorMessage)
fmt.Fprintln(os.Stderr)
printCommandline(os.Stderr)
fmt.Fprintln(os.Stderr, "For help, run: \x1b[1mmoor --help\x1b[m")
os.Exit(1)
}
logsRequested := *debug || *trace
if *printVersion {
fmt.Println(getVersion())
return nil, nil, chroma.Style{}, nil, logsRequested, nil
}
log.SetLevel(log.InfoLevel)
if *trace {
log.SetLevel(log.TraceLevel)
} else if *debug {
log.SetLevel(log.DebugLevel)
}
log.SetFormatter(&log.TextFormatter{
TimestampFormat: time.StampMicro,
})
flagSetArgs := flagSet.Args()
if stdinIsRedirected && len(flagSetArgs) == 0 {
// "-" is special if stdin is redirected, means "read from stdin"
//
// Ref: https://github.com/walles/moor/issues/162
flagSetArgs = []string{"-"}
}
// Check that any input files can be opened
for _, inputFilename := range flagSetArgs {
if stdinIsRedirected && inputFilename == "-" {
// stdin can be opened
continue
}
// Need to check before newScreen() below, otherwise the screen
// will be cleared before we print the "No such file" error.
err := reader.TryOpen(inputFilename)
if err != nil {
return nil, nil, chroma.Style{}, nil, logsRequested, err
}
}
if len(flagSetArgs) == 0 && !stdinIsRedirected {
fmt.Fprintln(os.Stderr, "ERROR: Filename(s) or input pipe required (\"moor file.txt\")")
fmt.Fprintln(os.Stderr)
printCommandline(os.Stderr)
fmt.Fprintln(os.Stderr, "For help, run: \x1b[1mmoor --help\x1b[m")
os.Exit(1)
}
if stdoutIsRedirected {
err := pumpToStdout(flagSetArgs...)
if err != nil {
return nil, nil, chroma.Style{}, nil, logsRequested, err
}
return nil, nil, chroma.Style{}, nil, logsRequested, nil
}
// INVARIANT: At this point, stdout is a terminal and we should proceed with
// paging.
stdoutIsTerminal := !stdoutIsRedirected
if !stdoutIsTerminal {
panic("Invariant broken: stdout is not a terminal")
}
formatter := formatters.TTY256
switch *terminalColorsCount {
case twin.ColorCount8:
formatter = formatters.TTY8
case twin.ColorCount16:
formatter = formatters.TTY16
case twin.ColorCount24bit:
formatter = formatters.TTY16m
}
var readerImpls []*reader.ReaderImpl
shouldFormat := *reFormat
readerOptions := reader.ReaderOptions{Lexer: *lexer, ShouldFormat: shouldFormat}
stdinName := ""
if os.Getenv("PAGER_LABEL") != "" {
stdinName = os.Getenv("PAGER_LABEL")
} else if os.Getenv("MAN_PN") != "" {
// MAN_PN is set by GNU man. Example value: "printf(1)"
stdinName = os.Getenv("MAN_PN")
}
// Display the input file(s) contents
stdinDone := false
for _, inputFilename := range flagSetArgs {
var readerImpl *reader.ReaderImpl
var err error
if stdinIsRedirected && inputFilename == "-" {
if stdinDone {
// stdin already drained, don't do it again
continue
}
readerImpl, err = reader.NewFromStream(stdinName, os.Stdin, formatter, readerOptions)
if err != nil {
return nil, nil, chroma.Style{}, nil, logsRequested, err
}
// If the user is doing "sudo something | moor" we can't show the UI until
// we start getting data, otherwise we'll mess up sudo's password prompt.
readerImpl.AwaitFirstByte()
stdinDone = true
} else {
readerImpl, err = reader.NewFromFilename(inputFilename, formatter, readerOptions)
}
if err != nil {
return nil, nil, chroma.Style{}, nil, logsRequested, err
}
readerImpls = append(readerImpls, readerImpl)
}
// We got the first byte, this means sudo is done (if it was used) and we
// can set up the UI.
screen, err := newScreen(*mouseMode, *terminalColorsCount)
if err != nil {
// Ref: https://github.com/walles/moor/issues/149
log.Info("Failed to set up screen for paging, pumping to stdout instead: ", err)
for _, readerImpl := range readerImpls {
readerImpl.PumpToStdout()
}
return nil, nil, chroma.Style{}, nil, logsRequested, nil
}
var style chroma.Style
if *styleOption == nil {
style = internal.GetStyleForScreen(screen)
} else {
style = **styleOption
}
log.Debug("Using style <", style.Name, ">")
for _, readerImpl := range readerImpls {
readerImpl.SetStyleForHighlighting(style)
}
pager := internal.NewPager(readerImpls...)
pager.WrapLongLines = *wrap
pager.ShowLineNumbers = !*noLineNumbers
pager.ShowStatusBar = !*noStatusBar
pager.DeInit = !*noClearOnExit
pager.DeInitFalseMargin = *noClearOnExitMargin
pager.QuitIfOneScreen = *quitIfOneScreen
pager.StatusBarStyle = *statusBarStyle
pager.UnprintableStyle = *unprintableStyle
pager.WithTerminalFg = *terminalFg
pager.ScrollLeftHint = *scrollLeftHint
pager.ScrollRightHint = *scrollRightHint
pager.SideScrollAmount = int(*shift)
pager.TabSize = int(*tabSize)
pager.WithSearchHitLineBackground = !*noSearchLineHighlight
pager.TargetLine = targetLine
if *follow && pager.TargetLine == nil {
reallyHigh := linemetadata.IndexMax()
pager.TargetLine = &reallyHigh
}
return pager, screen, style, &formatter, logsRequested, nil
}
func main() {
var loglines internal.LogWriter
logsRequested := false
log.SetOutput(&loglines)
twin.SetLogger(&util.TwinLogger{})
russiaNotSupported()
defer func() {
err := recover()
haveLogsToShow := len(loglines.String()) > 0 && logsRequested
if err == nil && !haveLogsToShow {
// No problems
return
}
printProblemsHeader()
if len(loglines.String()) > 0 {
fmt.Fprintln(os.Stderr)
// Consider not printing duplicate log messages more than once
fmt.Fprintf(os.Stderr, "%s", loglines.String())
}
if err != nil {
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Panic recovery timestamp:", time.Now().String())
fmt.Fprintln(os.Stderr)
panic(err)
}
// We were asked to print logs, and we did. Success!
os.Exit(0)
}()
stdinIsRedirected := !term.IsTerminal(int(os.Stdin.Fd()))
stdoutIsRedirected := !term.IsTerminal(int(os.Stdout.Fd()))
pager, screen, style, formatter, _logsRequested, err := pagerFromArgs(
os.Args,
twin.NewScreenWithMouseModeAndColorCount,
stdinIsRedirected,
stdoutIsRedirected,
)
logsRequested = _logsRequested
if err != nil {
fmt.Fprintln(os.Stderr, "ERROR:", err)
os.Exit(1)
}
if pager == nil {
// No pager, we're done
return
}
startPaging(pager, screen, &style, formatter)
}
// Define a generic flag with specified name, default value, and usage string.
// The return value is the address of a variable that stores the parsed value of
// the flag.
func flagSetFunc[T any](flagSet *flag.FlagSet, name string, defaultValue T, usage string, parser func(valueString string) (T, error)) *T {
parsed := defaultValue
flagSet.Func(name, usage, func(valueString string) error {
parseResult, err := parser(valueString)
if err != nil {
return err
}
parsed = parseResult
return nil
})
return &parsed
}
func startPaging(pager *internal.Pager, screen twin.Screen, chromaStyle *chroma.Style, chromaFormatter *chroma.Formatter) {
defer func() {
panicMessage := recover()
if panicMessage != nil {
// Clarify that any screen shutdown logs are from panic handling,
// not something the user or some external thing did.
log.Info("Panic detected, closing screen before informing the user...")
}
// Restore screen...
screen.Close()
// ... before printing any panic() output, otherwise the output will
// have broken linefeeds and be hard to follow.
if panicMessage != nil {
panic(panicMessage)
}
if !pager.DeInit {
pager.ReprintAfterExit()
}
if pager.AfterExit != nil {
err := pager.AfterExit()
if err != nil {
log.Error("Failed running AfterExit hook: ", err)
}
}
}()
pager.StartPaging(screen, chromaStyle, chromaFormatter)
}
moor-2.10.3/cmd/moor/moor_test.go 0000664 0000000 0000000 00000002736 15135744745 0016661 0 ustar 00root root 0000000 0000000 package main
import (
"testing"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/textstyles"
"github.com/walles/moor/v2/twin"
"gotest.tools/v3/assert"
)
func TestParseScrollHint(t *testing.T) {
token, err := parseScrollHint("ESC[7m>")
assert.NilError(t, err)
assert.Equal(t, token, textstyles.CellWithMetadata{
Rune: '>',
Style: twin.StyleDefault.WithAttr(twin.AttrReverse),
})
}
func TestPageOneInputFile(t *testing.T) {
pager, screen, _, formatter, _, err := pagerFromArgs(
[]string{"", "moor_test.go"},
func(_ twin.MouseMode, _ twin.ColorCount) (twin.Screen, error) {
return twin.NewFakeScreen(80, 24), nil
},
false, // stdin is redirected
false, // stdout is redirected
)
assert.NilError(t, err)
assert.Assert(t, pager != nil)
assert.Assert(t, screen != nil)
assert.Assert(t, formatter != nil)
}
func TestGetTargetLine(t *testing.T) {
index, remaining := getTargetLine([]string{})
assert.Assert(t, index == nil)
assert.DeepEqual(t, remaining, []string{})
index, remaining = getTargetLine([]string{"+"})
assert.Assert(t, index == nil)
assert.DeepEqual(t, remaining, []string{"+"})
// Ref: https://github.com/walles/moor/issues/316
index, remaining = getTargetLine([]string{"+0"})
assert.Assert(t, index == nil)
assert.DeepEqual(t, remaining, []string{})
index, remaining = getTargetLine([]string{"+1"})
assert.Equal(t, *index, linemetadata.IndexFromOneBased(1))
assert.DeepEqual(t, remaining, []string{})
}
moor-2.10.3/cmd/moor/usage.go 0000664 0000000 0000000 00000021115 15135744745 0015742 0 ustar 00root root 0000000 0000000 package main
import (
"flag"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/internal"
"github.com/walles/moor/v2/twin"
)
func renderLessTermcapEnvVar(envVarName string, description string, colors twin.ColorCount) string {
value := os.Getenv(envVarName)
if len(value) == 0 {
return ""
}
style, err := internal.TermcapToStyle(value)
if err != nil {
bold := twin.StyleDefault.WithAttr(twin.AttrBold).RenderUpdateFrom(twin.StyleDefault, colors)
notBold := twin.StyleDefault.RenderUpdateFrom(twin.StyleDefault.WithAttr(twin.AttrBold), colors)
return fmt.Sprintf(" %s (%s): %s %s<- Error: %v%s\n",
envVarName,
description,
strings.ReplaceAll(value, "\x1b", "ESC"),
bold,
err,
notBold,
)
}
prefix := style.RenderUpdateFrom(twin.StyleDefault, colors)
suffix := twin.StyleDefault.RenderUpdateFrom(style, colors)
return fmt.Sprintf(" %s (%s): %s\n",
envVarName,
description,
prefix+strings.ReplaceAll(value, "\x1b", "ESC")+suffix,
)
}
func renderPagerEnvVar(name string, colors twin.ColorCount) string {
bold := twin.StyleDefault.WithAttr(twin.AttrBold).RenderUpdateFrom(twin.StyleDefault, colors)
notBold := twin.StyleDefault.RenderUpdateFrom(twin.StyleDefault.WithAttr(twin.AttrBold), colors)
value, isSet := os.LookupEnv(name)
if value == "" {
what := "unset"
if isSet {
what = "empty"
}
return fmt.Sprintf(" %s is %s %s<- Should be %s%s\n",
name,
what,
bold,
getMoorPath(),
notBold,
)
}
absMoorPath, err := absLookPath(os.Args[0])
if err != nil {
log.Warn("Unable to find absolute moor path: ", err)
return ""
}
// You can actually have command line arguments in these variables (like
// MANPAGER="moor --reformat"), so we want to look at only the first word to
// find out whether this PAGER variable is pointing to the right binary.
firstWord := strings.Fields(value)[0]
absEnvValue, err := absLookPath(firstWord)
if err != nil {
// This can happen if this is set to some outdated value
absEnvValue = value
}
if absEnvValue == absMoorPath {
return fmt.Sprintf(" %s=%s\n", name, value)
}
return fmt.Sprintf(" %s=%s %s<- Should be %s%s\n",
name,
value,
bold,
getMoorPath(),
notBold,
)
}
// If the environment variable is set, render it as APA=bepa indented two
// spaces, plus a newline at the end. Otherwise, return an empty string.
func renderPlainEnvVar(envVarName string) string {
value := os.Getenv(envVarName)
if value == "" {
return ""
}
return fmt.Sprintf(" %s=%s\n", envVarName, value)
}
func printCommandline(output io.Writer) {
envVarName := moorEnvVarName()
envVarDescription := envVarName
if envVarName != "MOOR" {
bold := twin.StyleDefault.WithAttr(twin.AttrBold).RenderUpdateFrom(twin.StyleDefault, twin.ColorCount256)
notBold := twin.StyleDefault.RenderUpdateFrom(twin.StyleDefault.WithAttr(twin.AttrBold), twin.ColorCount256)
envVarDescription = envVarName + " (" + bold + "legacy, please use MOOR instead!" + notBold + ")"
}
fmt.Fprintln(output, "Commandline: moor", strings.Join(os.Args[1:], " ")) //nolint:errcheck
fmt.Fprintf(output, "Environment: %s=\"%v\"\n", envVarDescription, os.Getenv(envVarName)) //nolint:errcheck
fmt.Fprintln(output) //nolint:errcheck
}
func heading(text string, colors twin.ColorCount) string {
style := twin.StyleDefault.WithAttr(twin.AttrItalic)
prefix := style.RenderUpdateFrom(twin.StyleDefault, colors)
suffix := twin.StyleDefault.RenderUpdateFrom(style, colors)
return prefix + text + suffix
}
func printUsage(flagSet *flag.FlagSet, colors twin.ColorCount) {
// This controls where PrintDefaults() prints, see below
flagSet.SetOutput(os.Stdout)
// FIXME: Log if any printouts fail?
fmt.Println(heading("Usage", colors))
fmt.Println(" moor [options] ...")
fmt.Println(" ... | moor")
fmt.Println(" moor < file")
fmt.Println()
fmt.Println("Shows file contents. Compressed files will be transparently decompressed.")
fmt.Println("Input is expected to be (possibly compressed) UTF-8 encoded text. Invalid /")
fmt.Println("non-printable characters are by default rendered as '?'. Press : inside of")
fmt.Println("moor to switch between files.")
fmt.Println()
fmt.Println("More information + source code:")
fmt.Println(" ")
fmt.Println()
fmt.Println(heading("Environment", colors))
envVarName := moorEnvVarName()
envVarValue := os.Getenv(envVarName)
if len(envVarValue) == 0 {
fmt.Println(" Additional options are read from the MOOR environment variable if set.")
fmt.Println(" But currently, the MOOR environment variable is not set.")
} else {
fmt.Printf(" Additional options are read from the %s environment variable.\n", envVarName)
if envVarName != "MOOR" {
bold := twin.StyleDefault.WithAttr(twin.AttrBold).RenderUpdateFrom(twin.StyleDefault, colors)
notBold := twin.StyleDefault.RenderUpdateFrom(twin.StyleDefault.WithAttr(twin.AttrBold), colors)
fmt.Printf(
" But that is going away, %splease use the MOOR environment variable instead%s!\n",
bold,
notBold)
}
fmt.Printf(" Current setting: %s=\"%s\"\n", envVarName, envVarValue)
}
envSection := ""
envSection += renderLessTermcapEnvVar("LESS_TERMCAP_md", "man page bold style", colors)
envSection += renderLessTermcapEnvVar("LESS_TERMCAP_us", "man page underline style", colors)
envSection += renderLessTermcapEnvVar("LESS_TERMCAP_so", "search hits and footer style", colors)
envSection += renderPagerEnvVar("PAGER", colors)
envVars := os.Environ()
sort.Strings(envVars)
for _, env := range envVars {
split := strings.SplitN(env, "=", 2)
if len(split) != 2 {
continue
}
name := split[0]
if name == "PAGER" {
// Already done above
continue
}
if !strings.HasSuffix(name, "PAGER") {
continue
}
envSection += renderPagerEnvVar(name, colors)
}
envSection += renderPlainEnvVar("TERM")
envSection += renderPlainEnvVar("TERM_PROGRAM")
envSection += renderPlainEnvVar("COLORTERM")
// Requested here: https://github.com/walles/moor/issues/170#issuecomment-1891154661
envSection += renderPlainEnvVar("MANROFFOPT")
if envSection != "" {
fmt.Println()
// Not Println since the section already ends with a newline
fmt.Print(envSection)
}
printSetDefaultPagerHelp(colors)
fmt.Println()
fmt.Println(heading("Options", colors))
flagSet.PrintDefaults()
fmt.Println(" +1234")
fmt.Println(" \tImmediately scroll to line 1234")
}
// If $PAGER isn't pointing to us, print a help text on how to set it.
func printSetDefaultPagerHelp(colors twin.ColorCount) {
absMoorPath, err := absLookPath(os.Args[0])
if err != nil {
log.Warn("Unable to find moor binary ", err)
return
}
absPagerValue, err := absLookPath(os.Getenv("PAGER"))
if err != nil {
absPagerValue = ""
}
if absPagerValue == absMoorPath {
// We're already the default pager
return
}
fmt.Println()
fmt.Println(heading("Making moor Your Default Pager", colors))
shellIsFish := strings.HasSuffix(os.Getenv("SHELL"), "fish")
shellIsPowershell := len(os.Getenv("PSModulePath")) > 0
if shellIsFish {
fmt.Println(" Write this command at your prompt:")
fmt.Println()
fmt.Printf(" set -Ux PAGER %s\n", getMoorPath())
} else if shellIsPowershell {
fmt.Println(" Put the following line in your $PROFILE file (\"echo $PROFILE\" to find it)")
fmt.Println(" and moor will be used as the default pager in all new terminal windows:")
fmt.Println()
fmt.Printf(" $env:PAGER = \"%s\"\n", getMoorPath())
} else {
// I don't know how to identify bash / zsh, put generic instructions here
fmt.Println(" Put the following line in your ~/.bashrc, ~/.bash_profile or ~/.zshrc")
fmt.Println(" and moor will be used as the default pager in all new terminal windows:")
fmt.Println()
fmt.Printf(" export PAGER=%s\n", getMoorPath())
}
}
// "moor" if we're in the $PATH, otherwise an absolute path
func getMoorPath() string {
moorPath := os.Args[0]
if filepath.IsAbs(moorPath) {
return moorPath
}
if strings.Contains(moorPath, string(os.PathSeparator)) {
// Relative path
moorPath, err := filepath.Abs(moorPath)
if err != nil {
panic(err)
}
return moorPath
}
// Neither absolute nor relative, try PATH
_, err := exec.LookPath(moorPath)
if err != nil {
panic("Unable to find in $PATH: " + moorPath)
}
return moorPath
}
func absLookPath(path string) (string, error) {
lookedPath, err := exec.LookPath(path)
if err != nil {
return "", err
}
absLookedPath, err := filepath.Abs(lookedPath)
if err != nil {
return "", err
}
return absLookedPath, err
}
moor-2.10.3/go.mod 0000664 0000000 0000000 00000001136 15135744745 0013677 0 ustar 00root root 0000000 0000000 module github.com/walles/moor/v2
go 1.23.12
toolchain go1.24.4
require (
github.com/adrg/xdg v0.5.3
github.com/alecthomas/chroma/v2 v2.22.0
github.com/charlievieth/strcase v0.0.5
github.com/davecgh/go-spew v1.1.1
github.com/google/go-cmp v0.5.9
github.com/klauspost/compress v1.17.4
github.com/rivo/uniseg v0.4.7
github.com/sirupsen/logrus v1.8.3
github.com/ulikunitz/xz v0.5.15
golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc
golang.org/x/sys v0.26.0
golang.org/x/term v0.0.0-20210503060354-a79de5458b56
gotest.tools/v3 v3.3.0
)
require github.com/dlclark/regexp2 v1.11.5 // indirect
moor-2.10.3/go.sum 0000664 0000000 0000000 00000014670 15135744745 0013733 0 ustar 00root root 0000000 0000000 github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.22.0 h1:PqEhf+ezz5F5owoDeOUKFzW+W3ZJDShNCaHg4sZuItI=
github.com/alecthomas/chroma/v2 v2.22.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/charlievieth/strcase v0.0.5 h1:gV4iXVyD6eI5KdfOV+/vIVCKXZwtCWOmDMcu7Uy00Rs=
github.com/charlievieth/strcase v0.0.5/go.mod h1:FIOYY1aDBMSIOFqmVomHBpoK+bteGlESRsgsdWjrhx8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sirupsen/logrus v1.8.3 h1:DBBfY8eMYazKEJHb3JKpSPfpgd2mBCoNFlQx6C5fftU=
github.com/sirupsen/logrus v1.8.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc h1:ao2WRsKSzW6KuUY9IWPwWahcHCgR0s52IfwutMfEbdM=
golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.3.0 h1:MfDY1b1/0xN1CyMlQDac0ziEy9zJQd9CXBRRDHw2jJo=
gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A=
moor-2.10.3/install.sh 0000775 0000000 0000000 00000000271 15135744745 0014575 0 ustar 00root root 0000000 0000000 #!/bin/bash
set -e -o pipefail
./test.sh
echo
echo 'Installing into /usr/local/bin...'
cp moor /usr/local/bin/moor
echo
echo 'Installed, try "moor moor.go" to see moor in action!'
moor-2.10.3/internal/ 0000775 0000000 0000000 00000000000 15135744745 0014404 5 ustar 00root root 0000000 0000000 moor-2.10.3/internal/detectManPage.go 0000664 0000000 0000000 00000000517 15135744745 0017437 0 ustar 00root root 0000000 0000000 package internal
import (
"github.com/walles/moor/v2/internal/linemetadata"
)
func (p *Pager) haveLoadedManPage() bool {
reader := p.Reader()
if reader == nil {
return false
}
for _, line := range reader.GetLines(linemetadata.Index{}, 10).Lines {
if line.Line.HasManPageFormatting() {
return true
}
}
return false
}
moor-2.10.3/internal/editor.go 0000664 0000000 0000000 00000012550 15135744745 0016224 0 ustar 00root root 0000000 0000000 package internal
import (
"fmt"
"math"
"os"
"os/exec"
"runtime"
"strings"
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/reader"
)
// Dump the reader lines into a read-only temp file and return the absolute file
// name.
func dumpToTempFile(reader *reader.ReaderImpl) (string, error) {
tempFile, err := os.CreateTemp("", "moor-contents-")
if err != nil {
return "", err
}
defer func() {
err = tempFile.Close()
if err != nil {
log.Warn("Failed to close temp file: ", err)
}
}()
log.Debug("Dumping contents into: ", tempFile.Name())
lines := reader.GetLines(linemetadata.Index{}, math.MaxInt)
for _, line := range lines.Lines {
toWrite := line.Plain()
_, err := tempFile.WriteString(toWrite + "\n")
if err != nil {
return "", err
}
}
// Ref: https://pkg.go.dev/os#Chmod
err = os.Chmod(tempFile.Name(), 0400)
if err != nil {
// Doesn't matter that much, but if it fails we should at least log it
log.Debug("Failed to make temp file ", tempFile.Name(), " read-only: ", err)
}
return tempFile.Name(), nil
}
// Check that the editor is executable
func errUnlessExecutable(file string) error {
stat, err := os.Stat(file)
if err != nil {
return fmt.Errorf("Failed to stat %s: %w", file, err)
}
if runtime.GOOS == "windows" && strings.HasSuffix(strings.ToLower(file), ".exe") {
log.Debug(".exe file on Windows, assuming executable: ", file)
return nil
}
if stat.Mode()&0111 != 0 {
// Note that this check isn't perfect, it could still be executable but
// not by us. Corner case, let's just fail later in that case.
return nil
}
return fmt.Errorf("Not executable: %s", file)
}
func pickAnEditor() (string, string, error) {
// Get an editor setting from either VISUAL or EDITOR
editorEnv := "VISUAL"
editor := strings.TrimSpace(os.Getenv(editorEnv))
if editor == "" {
editorEnv := "EDITOR"
editor = strings.TrimSpace(os.Getenv(editorEnv))
}
if editor != "" {
return editor, editorEnv, nil
}
candidates := []string{
"vim", // This is a sucky default, but let's have it for compatibility with less
"nano",
"vi",
}
for _, candidate := range candidates {
fullPath, err := exec.LookPath(candidate)
log.Trace("Problem finding ", candidate, ": ", err)
if err != nil {
continue
}
err = errUnlessExecutable(fullPath)
log.Trace("Problem with executability of ", fullPath, ": ", err)
if err != nil {
continue
}
return candidate, "fallback list", nil
}
return "", "", fmt.Errorf("No editor found, tried: $VISUAL, $EDITOR, %s", strings.Join(candidates, ", "))
}
func handleEditingRequest(p *Pager) {
if os.Getenv("LESSSECURE") == "1" {
p.mode = &PagerModeInfo{
Pager: p,
Text: "Not launching editor since LESSSECURE=1 is set in the environment",
}
return
}
editor, editorEnv, err := pickAnEditor()
if err != nil {
log.Warn("Failed to find an editor: ", err)
return
}
// Tyre kicking check that we can find the editor either in the PATH or as
// an absolute path
firstWord := strings.Fields(editor)[0]
editorPath, err := exec.LookPath(firstWord)
if err != nil {
// FIXME: Show a message in the status bar instead? Nothing wrong with
// moor here.
log.Warn("Failed to find editor "+firstWord+" from $"+editorEnv+": ", err)
return
}
// Check that the editor is executable
err = errUnlessExecutable(editorPath)
if err != nil {
// FIXME: Show a message in the status bar instead? Nothing wrong with
// moor here.
log.Warn("Editor from {} not executable: {}", editorEnv, err)
return
}
canOpenFile := p.readers[p.currentReader].FileName != nil
if p.readers[p.currentReader].FileName != nil {
// Verify that the file exists and is readable
err = reader.TryOpen(*p.readers[p.currentReader].FileName)
if err != nil {
canOpenFile = false
log.Info("File to edit is not readable: ", err)
}
}
var fileToEdit string
if canOpenFile {
fileToEdit = *p.readers[p.currentReader].FileName
} else {
// NOTE: Let's not wait for the stream to finish, just dump whatever we
// have and open the editor on that. The user just asked for it, if they
// wanted to wait, they should have done that themselves.
// Create a temp file based on reader contents
fileToEdit, err = dumpToTempFile(p.readers[p.currentReader])
if err != nil {
log.Warn("Failed to create temp file to edit: ", err)
return
}
}
p.AfterExit = func() error {
// NOTE: If you do any changes here, make sure they work with both "nano"
// and "code -w" (VSCode).
commandWithArgs := strings.Fields(editor)
commandWithArgs = append(commandWithArgs, fileToEdit)
log.Info("'v' pressed, launching editor: ", commandWithArgs)
command := exec.Command(commandWithArgs[0], commandWithArgs[1:]...)
if runtime.GOOS == "windows" {
// Don't touch command.Stdin on Windows:
// https://github.com/walles/moor/issues/281#issuecomment-2953384726
} else {
// Since os.Stdin might come from a pipe, we can't trust that. Instead,
// we tell the editor to read from os.Stdout, which points to the
// terminal as well.
//
// Tested on macOS and Linux, works like a charm.
command.Stdin = os.Stdout // <- YES, WE SHOULD ASSIGN STDOUT TO STDIN
}
command.Stdout = os.Stdout
command.Stderr = os.Stderr
err := command.Run()
if err == nil {
log.Info("Editor exited successfully: ", commandWithArgs)
}
return err
}
p.Quit()
}
moor-2.10.3/internal/file-switching.go 0000664 0000000 0000000 00000001664 15135744745 0017656 0 ustar 00root root 0000000 0000000 package internal
import (
log "github.com/sirupsen/logrus"
)
func (p *Pager) previousFile() {
p.readerLock.Lock()
defer p.readerLock.Unlock()
newIndex := p.currentReader - 1
if newIndex < 0 {
newIndex = 0
}
p.currentReader = newIndex
log.Tracef("Switched to previous file, index %d", p.currentReader)
select {
case p.readerSwitched <- struct{}{}:
default:
}
}
func (p *Pager) nextFile() {
p.readerLock.Lock()
defer p.readerLock.Unlock()
newIndex := p.currentReader + 1
if newIndex >= len(p.readers) {
newIndex = len(p.readers) - 1
}
p.currentReader = newIndex
log.Tracef("Switched to next file, index %d", p.currentReader)
select {
case p.readerSwitched <- struct{}{}:
default:
}
}
func (p *Pager) firstFile() {
p.readerLock.Lock()
defer p.readerLock.Unlock()
p.currentReader = 0
log.Tracef("Switched to first file, index %d", p.currentReader)
select {
case p.readerSwitched <- struct{}{}:
default:
}
}
moor-2.10.3/internal/filteringReader.go 0000664 0000000 0000000 00000014574 15135744745 0020054 0 ustar 00root root 0000000 0000000 package internal
import (
"fmt"
"math"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/reader"
"github.com/walles/moor/v2/internal/search"
)
// Filters lines based on the search query from the pager.
type FilteringReader struct {
BackingReader reader.Reader
// This is a reference so that we can track changes to the original pattern,
// including if it is set to nil.
Filter *search.Search
// Protects filteredLinesCache, unfilteredLineCountWhenCaching, and
// filterPatternWhenCaching.
lock sync.Mutex
// nil means no filtering has happened yet
filteredLinesCache *[]reader.NumberedLine
// This is what the reader's line count was when we filtered. If the
// reader's current line count doesn't match, then our cache needs to be
// rebuilt.
unfilteredLineCountWhenCaching int
// This is the pattern that was used when we cached the lines. If it
// doesn't match the current pattern, then our cache needs to be rebuilt.
filterWhenCaching search.Search
}
// Please hold the lock when calling this method.
func (f *FilteringReader) rebuildCache() {
t0 := time.Now()
cache := make([]reader.NumberedLine, 0)
filter := *f.Filter
// Mark cache base conditions
f.unfilteredLineCountWhenCaching = f.BackingReader.GetLineCount()
f.filterWhenCaching = filter
// Repopulate the cache
allBaseLines := f.BackingReader.GetLines(linemetadata.Index{}, math.MaxInt)
resultIndex := 0
for _, line := range allBaseLines.Lines {
if filter.Active() && !filter.Matches(line.Line.Plain(line.Index)) {
// We have a pattern but it doesn't match
continue
}
cache = append(cache, reader.NumberedLine{
Line: line.Line,
Index: linemetadata.IndexFromZeroBased(resultIndex),
Number: line.Number,
})
resultIndex++
}
f.filteredLinesCache = &cache
log.Debugf("Filtered out %d/%d lines in %s",
len(allBaseLines.Lines)-len(cache), len(allBaseLines.Lines), time.Since(t0))
}
func (f *FilteringReader) getAllLines() []reader.NumberedLine {
f.lock.Lock()
defer f.lock.Unlock()
if f.filteredLinesCache == nil {
f.rebuildCache()
return *f.filteredLinesCache
}
if f.unfilteredLineCountWhenCaching != f.BackingReader.GetLineCount() {
f.rebuildCache()
return *f.filteredLinesCache
}
var currentFilterPattern search.Search
if (*f).Filter.Active() {
currentFilterPattern = *f.Filter
}
var cacheFilterPattern search.Search
if f.filterWhenCaching.Active() {
cacheFilterPattern = f.filterWhenCaching
}
if !currentFilterPattern.Equals(cacheFilterPattern) {
f.rebuildCache()
return *f.filteredLinesCache
}
return *f.filteredLinesCache
}
func (f *FilteringReader) shouldPassThrough() bool {
f.lock.Lock()
defer f.lock.Unlock()
if f.Filter == nil || f.Filter.Inactive() {
// Cache is not needed
f.filteredLinesCache = nil
// No filtering, so pass through all
return true
}
return false
}
func (f *FilteringReader) GetLineCount() int {
if f.shouldPassThrough() {
return f.BackingReader.GetLineCount()
}
return len(f.getAllLines())
}
func (f *FilteringReader) ShouldShowLineCount() bool {
panic("Unexpected call to FilteringReader.ShouldShowLineCount()")
}
func (f *FilteringReader) GetLine(index linemetadata.Index) *reader.NumberedLine {
if f.shouldPassThrough() {
return f.BackingReader.GetLine(index)
}
allLines := f.getAllLines()
if index.Index() < 0 || index.Index() >= len(allLines) {
return nil
}
return &allLines[index.Index()]
}
func (f *FilteringReader) GetLines(firstLine linemetadata.Index, wantedLineCount int) reader.InputLines {
if f.shouldPassThrough() {
return f.BackingReader.GetLines(firstLine, wantedLineCount)
}
acceptedLines := f.getAllLines()
if len(acceptedLines) == 0 || wantedLineCount == 0 {
return reader.InputLines{
StatusText: f.createStatus(nil),
}
}
lastLine := firstLine.NonWrappingAdd(wantedLineCount - 1)
// Prevent reading past the end of the available lines
maxLineIndex := *linemetadata.IndexFromLength(len(acceptedLines))
if lastLine.IsAfter(maxLineIndex) {
lastLine = maxLineIndex
// If one line was requested, then first and last should be exactly the
// same, and we would get there by adding zero.
firstLine = lastLine.NonWrappingAdd(1 - wantedLineCount)
return f.GetLines(firstLine, firstLine.CountLinesTo(lastLine))
}
return reader.InputLines{
Lines: acceptedLines[firstLine.Index() : firstLine.Index()+wantedLineCount],
StatusText: f.createStatus(&lastLine),
}
}
func (f *FilteringReader) GetLinesPreallocated(firstLine linemetadata.Index, resultLines *[]reader.NumberedLine) (string, string) {
if f.shouldPassThrough() {
return f.BackingReader.GetLinesPreallocated(firstLine, resultLines)
}
lines := f.GetLines(firstLine, cap(*resultLines))
*resultLines = lines.Lines
return lines.FilenameText, lines.StatusText
}
// In the general case, this will return a text like this:
// "Filtered: 1234/5678 lines 22%"
func (f *FilteringReader) createStatus(lastLine *linemetadata.Index) string {
baseCount := f.BackingReader.GetLineCount()
if baseCount == 0 {
return "Filtered: No input lines"
}
baseCountString := "/" + linemetadata.IndexFromLength(baseCount).Format()
if !f.BackingReader.ShouldShowLineCount() {
baseCountString = ""
}
if lastLine == nil {
// 100% because we're showing all 0 lines
return "Filtered: 0" + baseCountString + " lines 100%"
}
acceptedCount := f.GetLineCount()
acceptedCountString := linemetadata.IndexFromLength(acceptedCount).Format()
percent := int(math.Floor(100 * float64(lastLine.Index()+1) / float64(acceptedCount)))
lineString := "line"
if (len(baseCountString) > 0 && baseCount != 1) || (len(baseCountString) == 0 && acceptedCount != 1) {
lineString += "s"
}
return fmt.Sprintf("Filtered: %s%s %s %d%%",
acceptedCountString, baseCountString, lineString, percent)
}
// SetBackingReader switches the underlying reader while holding the lock and
// clears all cached state so that subsequent calls will rebuild using the new
// reader.
//
// This avoids data races that would occur if the entire FilteringReader struct
// (and its mutex) were reassigned concurrently.
func (f *FilteringReader) SetBackingReader(r reader.Reader) {
f.lock.Lock()
defer f.lock.Unlock()
f.BackingReader = r
// Invalidate caches so they will be rebuilt lazily on next access.
f.filteredLinesCache = nil
f.unfilteredLineCountWhenCaching = -1
f.filterWhenCaching = search.Search{}
}
moor-2.10.3/internal/inputbox.go 0000664 0000000 0000000 00000014407 15135744745 0016611 0 ustar 00root root 0000000 0000000 package internal
import (
"unicode"
"github.com/walles/moor/v2/twin"
)
type InputBoxOnTextChanged func(text string)
type AcceptMode int
const (
INPUTBOX_ACCEPT_ALL AcceptMode = iota
INPUTBOX_ACCEPT_POSITIVE_NUMBERS
)
type InputBox struct {
text string
// accept controls what input is accepted. Use the INPUTBOX_ACCEPT_*
// constants defined above.
accept AcceptMode
// cursorPos is the insertion point in runes (0 == before first rune,
// len(runes) == after last rune).
cursorPos int
// onTextChanged is an optional callback which is triggered when the text
// of the InputBox changes.
onTextChanged InputBoxOnTextChanged
}
// draw renders the input box at the bottom line of the screen, showing a
// simple prompt and the current text with a reverse attribute cursor.
func (b *InputBox) draw(screen twin.Screen, keys_help string, prompt string) {
width, height := screen.Size()
pos := 0
// Draw the prompt first
for _, ch := range prompt {
pos += screen.SetCell(pos, height-1, twin.NewStyledRune(ch, twin.StyleDefault))
}
// Work with runes for cursor correctness
textRunes := []rune(b.text)
if b.cursorPos < 0 {
b.cursorPos = 0
}
if b.cursorPos > len(textRunes) {
b.cursorPos = len(textRunes)
}
// Draw left side (before cursor)
for i, ch := range textRunes {
if i == b.cursorPos {
break
}
pos += screen.SetCell(pos, height-1, twin.NewStyledRune(ch, twin.StyleDefault))
}
// If cursor is on a rune, invert that rune. If cursor is at the end,
// show an inverted blank cell.
if b.cursorPos < len(textRunes) {
pos += screen.SetCell(pos, height-1, twin.NewStyledRune(textRunes[b.cursorPos], twin.StyleDefault.WithAttr(twin.AttrReverse)))
// Draw right side after the cursor rune
for i := b.cursorPos + 1; i < len(textRunes); i++ {
pos += screen.SetCell(pos, height-1, twin.NewStyledRune(textRunes[i], twin.StyleDefault))
}
} else {
// Cursor at end -> reverse blank
pos += screen.SetCell(pos, height-1, twin.NewStyledRune(' ', twin.StyleDefault.WithAttr(twin.AttrReverse)))
}
afterTextPos := pos
// Clear the rest of the line
for pos < width {
pos += screen.SetCell(pos, height-1, twin.NewStyledRune(' ', twin.StyleDefault))
}
// Draw help on the right
if len(keys_help) > 0 {
renderedHelp := renderHelpText(keys_help)
helpStart := width - len(renderedHelp)
if helpStart > afterTextPos {
// Draw the help text
pos = width - len(renderedHelp)
for _, cell := range renderedHelp {
pos += screen.SetCell(pos, height-1, cell)
}
screen.SetCell(pos, height-1, twin.NewStyledRune(' ', statusbarStyle))
}
}
}
func (b *InputBox) setText(text string) {
b.text = text
b.moveCursorEnd()
if b.onTextChanged != nil {
b.onTextChanged(b.text)
}
}
// handleRune appends runes to the text of the InputBox and returns if those have been processed.
// (Some keyboards send 0x08 instead of backspace, so we support it here too).
func (b *InputBox) handleRune(char rune) bool {
if char == '\x08' {
b.backspace()
return true
}
if char == '\x01' {
// Ctrl-A, move cursor to start
b.moveCursorHome()
return true
}
if char == '\x05' {
// Ctrl-E, move cursor to end
b.moveCursorEnd()
return true
}
if char == '\x02' {
// Ctrl-B, move cursor left
b.moveCursorLeft()
return true
}
if char == '\x06' {
// Ctrl-F, move cursor right
b.moveCursorRight()
return true
}
if char == '\x0b' {
// Ctrl-K, delete to end of line
b.deleteToEnd()
return true
}
if char == '\x15' {
// Ctrl-U, delete to start of line
b.deleteToStart()
return true
}
// If configured to accept numbers only, drop any non-digit rune.
if b.accept == INPUTBOX_ACCEPT_POSITIVE_NUMBERS {
if !unicode.IsDigit(char) {
return false
}
}
// Insert at cursor position
runes := []rune(b.text)
if b.cursorPos < 0 {
b.cursorPos = 0
}
if b.cursorPos > len(runes) {
b.cursorPos = len(runes)
}
// Build a new rune slice with the inserted rune
newRunes := make([]rune, 0, len(runes)+1)
newRunes = append(newRunes, runes[:b.cursorPos]...)
newRunes = append(newRunes, char)
if b.cursorPos < len(runes) {
newRunes = append(newRunes, runes[b.cursorPos:]...)
}
b.text = string(newRunes)
b.cursorPos++
// finally let's tell someone that the text has changed
if b.onTextChanged != nil {
b.onTextChanged(b.text)
}
return true
}
// handleKey processes special keys like backspace, delete, arrow keys, home and end.
// Returns true if the key was processed, false otherwise.
func (b *InputBox) handleKey(key twin.KeyCode) bool {
switch key {
case twin.KeyLeft:
b.moveCursorLeft()
return true
case twin.KeyRight:
b.moveCursorRight()
return true
case twin.KeyHome:
b.moveCursorHome()
return true
case twin.KeyEnd:
b.moveCursorEnd()
return true
case twin.KeyBackspace:
b.backspace()
return true
case twin.KeyDelete:
b.delete()
return true
}
return false
}
// moveCursorLeft moves the cursor one rune to the left.
func (b *InputBox) moveCursorLeft() {
if b.cursorPos > 0 {
b.cursorPos--
}
}
// moveCursorRight moves the cursor one rune to the right.
func (b *InputBox) moveCursorRight() {
if b.cursorPos < len([]rune(b.text)) {
b.cursorPos++
}
}
// moveCursorHome moves the cursor to the start of the text.
func (b *InputBox) moveCursorHome() {
b.cursorPos = 0
}
// moveCursorEnd moves the cursor to the end of the text.
func (b *InputBox) moveCursorEnd() {
b.cursorPos = len([]rune(b.text))
}
func (b *InputBox) deleteToEnd() {
b.text = string([]rune(b.text)[:b.cursorPos])
if b.onTextChanged != nil {
b.onTextChanged(b.text)
}
}
func (b *InputBox) deleteToStart() {
b.text = string([]rune(b.text)[b.cursorPos:])
b.cursorPos = 0
if b.onTextChanged != nil {
b.onTextChanged(b.text)
}
}
// backspace removes the rune before the cursor and moves the cursor left.
func (b *InputBox) backspace() {
runes := []rune(b.text)
if b.cursorPos > 0 && len(runes) > 0 {
runes = append(runes[:b.cursorPos-1], runes[b.cursorPos:]...)
b.cursorPos--
b.text = string(runes)
if b.onTextChanged != nil {
b.onTextChanged(b.text)
}
}
}
// delete removes the rune at the cursor.
func (b *InputBox) delete() {
runes := []rune(b.text)
if b.cursorPos < len(runes) {
runes = append(runes[:b.cursorPos], runes[b.cursorPos+1:]...)
b.text = string(runes)
if b.onTextChanged != nil {
b.onTextChanged(b.text)
}
}
}
moor-2.10.3/internal/inputbox_test.go 0000664 0000000 0000000 00000004307 15135744745 0017646 0 ustar 00root root 0000000 0000000 package internal
import (
"testing"
"github.com/walles/moor/v2/twin"
"gotest.tools/v3/assert"
)
func TestInsertAndBackspace(t *testing.T) {
screen := twin.NewFakeScreen(40, 2)
b := &InputBox{accept: INPUTBOX_ACCEPT_ALL}
assert.Assert(t, b.handleRune('a'))
assert.Assert(t, b.handleRune('b'))
assert.Assert(t, b.handleRune('c'))
assert.Equal(t, "abc", b.text)
// Backspace
b.backspace()
assert.Equal(t, "ab", b.text)
// Draw and inspect status line
b.draw(screen, "", "P: ")
row := rowToString(screen.GetRow(1))
assert.Equal(t, "P: ab", row)
}
func TestCursorMovementAndInsertDelete(t *testing.T) {
screen := twin.NewFakeScreen(80, 2)
b := &InputBox{accept: INPUTBOX_ACCEPT_ALL}
b.handleRune('a')
b.handleRune('b')
b.handleRune('c')
assert.Equal(t, "abc", b.text)
// Move left twice, insert 'X' between a and b
b.moveCursorLeft()
b.moveCursorLeft()
assert.Assert(t, b.handleRune('X'))
assert.Equal(t, "aXbc", b.text)
// Delete at cursor (cursor is after X)
b.delete()
assert.Equal(t, "aXc", b.text)
// Move home and insert
b.moveCursorHome()
assert.Assert(t, b.handleRune('S'))
assert.Equal(t, "SaXc", b.text)
// Move end and append
b.moveCursorEnd()
assert.Assert(t, b.handleRune('E'))
assert.Equal(t, "SaXcE", b.text)
b.draw(screen, "", "G: ")
row := rowToString(screen.GetRow(1))
assert.Equal(t, "G: SaXcE", row)
}
func TestAcceptPositiveNumbers(t *testing.T) {
b := &InputBox{accept: INPUTBOX_ACCEPT_POSITIVE_NUMBERS}
assert.Assert(t, b.handleRune('1'))
assert.Assert(t, !b.handleRune('a'))
assert.Assert(t, b.handleRune('2'))
assert.Equal(t, "12", b.text)
}
func TestUnicodeRunes(t *testing.T) {
screen := twin.NewFakeScreen(80, 2)
b := &InputBox{accept: INPUTBOX_ACCEPT_ALL}
// Insert a CJK char and an emoji
assert.Assert(t, b.handleRune('åˆ'))
assert.Assert(t, b.handleRune('ðŸ§'))
assert.Equal(t, "åˆðŸ§", b.text)
// Backspace should remove the emoji
b.backspace()
assert.Equal(t, "åˆ", b.text)
// Insert another wide char at start
b.moveCursorHome()
assert.Assert(t, b.handleRune('ä½ '))
assert.Equal(t, "ä½ åˆ", b.text)
b.draw(screen, "", "U: ")
row := rowToString(screen.GetRow(1))
// We expect prompt + two runes
assert.Equal(t, "U: ä½ åˆ", row)
}
moor-2.10.3/internal/linemetadata/ 0000775 0000000 0000000 00000000000 15135744745 0017034 5 ustar 00root root 0000000 0000000 moor-2.10.3/internal/linemetadata/index.go 0000664 0000000 0000000 00000004065 15135744745 0020477 0 ustar 00root root 0000000 0000000 package linemetadata
import (
"fmt"
"math"
"github.com/walles/moor/v2/internal/util"
)
// This represents a (zero based) index into an array of lines
type Index struct {
index int
}
func (i Index) Index() int {
return i.index
}
func IndexFromZeroBased(zeroBased int) Index {
if zeroBased < 0 {
panic(fmt.Errorf("zero-based line indices must be at least 0, got %d", zeroBased))
}
return Index{index: zeroBased}
}
func IndexFromOneBased(oneBased int) Index {
if oneBased < 1 {
panic(fmt.Errorf("one-based line indices must be at least 1, got %d", oneBased))
}
return Index{index: oneBased - 1}
}
// The highest possible line index
func IndexMax() Index {
return Index{index: math.MaxInt}
}
// Set the line index to the last line of a file with the given number of lines
// in it. Or nil if the line count is 0.
func IndexFromLength(length int) *Index {
if length == 0 {
return nil
}
if length < 0 {
panic(fmt.Errorf("line count must be at least 0, got %d", length))
}
return &Index{index: length - 1}
}
// Returns a new index between 0 and math.MaxInt
func (i Index) NonWrappingAdd(offset int) Index {
if offset > 0 {
if i.index > math.MaxInt-offset {
return Index{index: math.MaxInt}
}
} else {
if i.index < -offset {
return Index{index: 0}
}
}
return Index{index: i.index + offset}
}
// First line will be formatted as "1".
func (i Index) Format() string {
return util.FormatInt(i.index + 1)
}
func (i Index) IsBefore(other Index) bool {
return i.index < other.index
}
func (i Index) IsAfter(other Index) bool {
return i.index > other.index
}
// If both indices are the same this method will return 1.
func (i Index) CountLinesTo(next Index) int {
if i.index > next.index {
panic(fmt.Errorf("line indices must be ordered, got %s-%s", i.Format(), next.Format()))
}
return 1 + next.index - i.index
}
func (i Index) IsZero() bool {
return i.index == 0
}
func (i Index) IsWithinLength(length int) bool {
if length < 0 {
panic(fmt.Errorf("line count must be at least 0, got %d", length))
}
return i.index >= 0 && i.index < length
}
moor-2.10.3/internal/linemetadata/number.go 0000664 0000000 0000000 00000004203 15135744745 0020652 0 ustar 00root root 0000000 0000000 package linemetadata
import (
"fmt"
"math"
"github.com/walles/moor/v2/internal/util"
)
// This represents a line number in an input stream
type Number struct {
number int
}
func (l Number) AsOneBased() int {
if l.number == math.MaxInt {
return math.MaxInt
}
return l.number + 1
}
// FIXME: Maybe drop this in favor of some array access method(s)?
func (l Number) AsZeroBased() int {
return l.number
}
func NumberFromOneBased(oneBased int) Number {
if oneBased < 1 {
panic(fmt.Errorf("one-based line numbers must be at least 1, got %d", oneBased))
}
return Number{number: oneBased - 1}
}
func NumberFromZeroBased(zeroBased int) Number {
if zeroBased < 0 {
panic(fmt.Errorf("zero-based line numbers must be at least 0, got %d", zeroBased))
}
return Number{number: zeroBased}
}
// The highest possible line number
func NumberMax() Number {
return Number{number: math.MaxInt}
}
// Set the line number to the last line of a file with the given number of lines
// in it. Or nil if the line count is 0.
func NumberFromLength(length int) *Number {
if length == 0 {
return nil
}
if length < 0 {
panic(fmt.Errorf("line count must be at least 0, got %d", length))
}
return &Number{number: length - 1}
}
func (l Number) NonWrappingAdd(offset int) Number {
if offset > 0 {
if l.AsZeroBased() > math.MaxInt-offset {
return NumberFromZeroBased(math.MaxInt)
}
} else {
if l.AsZeroBased() < -offset {
return NumberFromZeroBased(0)
}
}
return NumberFromZeroBased(l.number + offset)
}
func (l Number) Format() string {
return util.FormatInt(l.AsOneBased())
}
// If both lines are the same this method will return 1.
func (l Number) CountLinesTo(next Number) int {
if l.number > next.number {
panic(fmt.Errorf("line numbers must be ordered, got %s-%s", l.Format(), next.Format()))
}
return 1 + next.AsZeroBased() - l.AsZeroBased()
}
// Is this the lowest possible line number?
func (l Number) IsZero() bool {
return l.AsZeroBased() == 0
}
func (l Number) IsBefore(other Number) bool {
return l.AsZeroBased() < other.AsZeroBased()
}
func (l Number) IsAfter(other Number) bool {
return l.AsZeroBased() > other.AsZeroBased()
}
moor-2.10.3/internal/linemetadata/number_test.go 0000664 0000000 0000000 00000003774 15135744745 0021725 0 ustar 00root root 0000000 0000000 package linemetadata
import (
"math"
"testing"
"gotest.tools/v3/assert"
)
func TestNonWrappingAddPositive(t *testing.T) {
base := NumberFromZeroBased(math.MaxInt - 2)
assert.Equal(t, base.NonWrappingAdd(1), NumberFromZeroBased(math.MaxInt-1))
assert.Equal(t, base.NonWrappingAdd(2), NumberFromZeroBased(math.MaxInt))
assert.Equal(t, base.NonWrappingAdd(3), NumberFromZeroBased(math.MaxInt))
}
func TestNonWrappingAddNegative(t *testing.T) {
base := NumberFromZeroBased(2)
assert.Equal(t, base.NonWrappingAdd(-1), NumberFromZeroBased(1))
assert.Equal(t, base.NonWrappingAdd(-2), NumberFromZeroBased(0))
assert.Equal(t, base.NonWrappingAdd(-3), NumberFromZeroBased(0))
}
func TestLineNumberFormatting(t *testing.T) {
assert.Equal(t, "1", NumberFromOneBased(1).Format())
assert.Equal(t, "10", NumberFromOneBased(10).Format())
assert.Equal(t, "100", NumberFromOneBased(100).Format())
// Ref: // https://en.wikipedia.org/wiki/Decimal_separator#Exceptions_to_digit_grouping
assert.Equal(t, "1000", NumberFromOneBased(1000).Format())
assert.Equal(t, "10_000", NumberFromOneBased(10000).Format())
assert.Equal(t, "100_000", NumberFromOneBased(100000).Format())
assert.Equal(t, "1_000_000", NumberFromOneBased(1000000).Format())
assert.Equal(t, "10_000_000", NumberFromOneBased(10000000).Format())
}
func TestNumberFromLength(t *testing.T) {
// If the file has one line then the last zero based line number is 0.
fromLength := NumberFromLength(1)
assert.Equal(t, *fromLength, NumberFromZeroBased(0))
}
func TestLineNumberEquality(t *testing.T) {
assert.Equal(t, NumberFromZeroBased(1), NumberFromOneBased(2),
"Two different ways of representing the same line number should be equal")
}
func TestLineNumberCountLinesTo(t *testing.T) {
assert.Equal(t,
NumberFromZeroBased(0).CountLinesTo(NumberFromZeroBased(0)),
1, // Count is inclusive, so countint from 0 to 0 is 1
)
assert.Equal(t,
NumberFromZeroBased(0).CountLinesTo(NumberFromZeroBased(1)),
2, // Count is inclusive, so countint from 0 to 1 is 2
)
}
moor-2.10.3/internal/linewrapper.go 0000664 0000000 0000000 00000010062 15135744745 0017262 0 ustar 00root root 0000000 0000000 package internal
import (
"unicode"
"github.com/walles/moor/v2/internal/textstyles"
)
// From: https://www.compart.com/en/unicode/U+00A0
//
//revive:disable-next-line:var-naming
const NO_BREAK_SPACE = '\xa0'
// Given some text and a maximum width in screen cells, find the best point at
// which to wrap the text. Return value is in number of runes.
func getWrapCount(line []textstyles.CellWithMetadata, maxScreenCellsCount int) int {
screenCells := 0
bestCutPoint := maxScreenCellsCount
inLeadingWhitespace := true
for cutBeforeThisIndex := 0; cutBeforeThisIndex <= maxScreenCellsCount; cutBeforeThisIndex++ {
canBreakHere := false
char := line[cutBeforeThisIndex].Rune
onBreakableSpace := unicode.IsSpace(char) && char != NO_BREAK_SPACE
if onBreakableSpace && !inLeadingWhitespace {
// Break-OK whitespace, cut before this one!
canBreakHere = true
}
if !onBreakableSpace {
inLeadingWhitespace = false
}
// Accept cutting inside "]("" in Markdown links: [home](http://127.0.0.1)
if cutBeforeThisIndex > 0 {
previousChar := line[cutBeforeThisIndex-1].Rune
if previousChar == ']' && char == '(' {
canBreakHere = true
}
}
// Break after single slashes, this is to enable breaking inside URLs / paths
if cutBeforeThisIndex > 1 {
beforeSlash := line[cutBeforeThisIndex-2].Rune
slash := line[cutBeforeThisIndex-1].Rune
afterSlash := char
if beforeSlash != '/' && slash == '/' && afterSlash != '/' {
canBreakHere = true
}
}
if cutBeforeThisIndex > 0 {
// Break after a hyphen / dash. That's something people do.
previousChar := line[cutBeforeThisIndex-1].Rune
if previousChar == '-' && char != '-' {
canBreakHere = true
}
}
if canBreakHere {
bestCutPoint = cutBeforeThisIndex
}
screenCells += line[cutBeforeThisIndex].Width()
if screenCells > maxScreenCellsCount {
// We went too far
if bestCutPoint > cutBeforeThisIndex {
// We have to cut here
bestCutPoint = cutBeforeThisIndex
}
break
}
}
return bestCutPoint
}
// How many screen cells wide will this line be?
func getScreenCellCount(runes []textstyles.CellWithMetadata) int {
cellCount := 0
for _, rune := range runes {
cellCount += rune.Width()
}
return cellCount
}
// Wrap one line of text to a maximum width.
//
// The return value will not contain any trailers, but the ContainsSearchHit
// field will be correctly set for sub-lines with search hits.
func wrapLine(width int, line textstyles.CellWithMetadataSlice) []textstyles.StyledRunesWithTrailer {
// Trailing space risks showing up by itself on a line, which would just
// look weird.
line = line.WithoutSpaceRight()
screenCellCount := getScreenCellCount(line)
if screenCellCount == 0 {
return []textstyles.StyledRunesWithTrailer{{}}
}
wrapped := make([]textstyles.StyledRunesWithTrailer, 0, len(line)/width)
for screenCellCount > width {
wrapWidth := getWrapCount(line, width)
firstPart := line[:wrapWidth]
isOnFirstLine := len(wrapped) == 0
if !isOnFirstLine {
// Leading whitespace on wrapped lines would just look like
// indentation, which would be weird for wrapped text.
firstPart = firstPart.WithoutSpaceLeft()
}
wrapped = append(wrapped,
textstyles.StyledRunesWithTrailer{
StyledRunes: firstPart.WithoutSpaceRight(),
ContainsSearchHit: firstPart.ContainsSearchHit(),
},
)
// These runes still need processing
remaining := line[wrapWidth:].WithoutSpaceLeft()
// Track how many screen cells are left to handle
handledCount := len(line) - len(remaining)
screenCellCount -= getScreenCellCount(line[:handledCount])
// Prepare for the next iteration
line = remaining
}
isOnFirstLine := len(wrapped) == 0
if !isOnFirstLine {
// Leading whitespace on wrapped lines would just look like
// indentation, which would be weird for wrapped text.
line = line.WithoutSpaceLeft()
}
line = line.WithoutSpaceRight()
if len(line) > 0 {
wrapped = append(wrapped,
textstyles.StyledRunesWithTrailer{
StyledRunes: line,
ContainsSearchHit: line.ContainsSearchHit(),
},
)
}
return wrapped
}
moor-2.10.3/internal/linewrapper_test.go 0000664 0000000 0000000 00000011727 15135744745 0020332 0 ustar 00root root 0000000 0000000 package internal
import (
"testing"
"gotest.tools/v3/assert"
"github.com/walles/moor/v2/internal/textstyles"
"github.com/walles/moor/v2/twin"
log "github.com/sirupsen/logrus"
)
func tokenize(input string) []textstyles.CellWithMetadata {
return textstyles.StyledRunesFromString(twin.StyleDefault, input, nil, 0).StyledRunes
}
func rowsToString(cellLines []textstyles.CellWithMetadataSlice) string {
returnMe := ""
for _, cellLine := range cellLines {
lineString := ""
for _, cell := range cellLine {
lineString += string(cell.Rune)
}
if len(returnMe) > 0 {
returnMe += "\n"
}
returnMe += "<" + lineString + ">"
}
return returnMe
}
func assertWrap(t *testing.T, input string, widthInScreenCells int, wrappedLines ...string) {
toWrap := tokenize(input)
wrapped := wrapLine(widthInScreenCells, toWrap)
actual := make([]textstyles.CellWithMetadataSlice, len(wrapped))
for i, w := range wrapped {
actual[i] = w.StyledRunes
}
expected := []textstyles.CellWithMetadataSlice{}
for _, wrappedLine := range wrappedLines {
expected = append(expected, tokenize(wrappedLine))
}
equal := true
if len(actual) != len(expected) {
equal = false
} else {
for i := range actual {
if !actual[i].Equal(expected[i]) {
equal = false
break
}
}
}
if equal {
return
}
t.Errorf("When wrapping <%s> at cell count %d:\n--Expected--\n%s\n\n--Actual--\n%s",
input, widthInScreenCells, rowsToString(expected), rowsToString(actual))
}
func TestEnoughRoomNoWrapping(t *testing.T) {
assertWrap(t, "This is a test", 20, "This is a test")
}
func TestWrapBlank(t *testing.T) {
assertWrap(t, " ", 4, "")
assertWrap(t, " ", 2, "")
assertWrap(t, "", 20, "")
}
func TestWordLongerThanLine(t *testing.T) {
assertWrap(t, "intermediary", 6, "interm", "ediary")
}
func TestLeadingSpaceNoWrap(t *testing.T) {
assertWrap(t, " abc", 20, " abc")
}
func TestLeadingSpaceWithWrap(t *testing.T) {
assertWrap(t, " abc", 2, " a", "bc")
}
func TestLeadingWrappedSpace(t *testing.T) {
assertWrap(t, "ab cd", 2, "ab", "cd")
}
func TestWordWrap(t *testing.T) {
assertWrap(t, "abc 123", 8, "abc 123")
assertWrap(t, "abc 123", 7, "abc 123")
assertWrap(t, "abc 123", 6, "abc", "123")
assertWrap(t, "abc 123", 5, "abc", "123")
assertWrap(t, "abc 123", 4, "abc", "123")
assertWrap(t, "abc 123", 3, "abc", "123")
assertWrap(t, "abc 123", 2, "ab", "c", "12", "3")
assertWrap(t, "here's the last line", 10, "here's the", "last line")
}
func TestWordWrapUrl(t *testing.T) {
assertWrap(t, "http://apa/bepa/", 17, "http://apa/bepa/")
assertWrap(t, "http://apa/bepa/", 16, "http://apa/bepa/")
assertWrap(t, "http://apa/bepa/", 15, "http://apa/", "bepa/")
assertWrap(t, "http://apa/bepa/", 14, "http://apa/", "bepa/")
assertWrap(t, "http://apa/bepa/", 13, "http://apa/", "bepa/")
assertWrap(t, "http://apa/bepa/", 12, "http://apa/", "bepa/")
assertWrap(t, "http://apa/bepa/", 11, "http://apa/", "bepa/")
assertWrap(t, "http://apa/bepa/", 10, "http://apa", "/bepa/")
assertWrap(t, "http://apa/bepa/", 9, "http://ap", "a/bepa/")
assertWrap(t, "http://apa/bepa/", 8, "http://a", "pa/bepa/")
assertWrap(t, "http://apa/bepa/", 7, "http://", "apa/", "bepa/")
assertWrap(t, "http://apa/bepa/", 6, "http:/", "/apa/", "bepa/")
assertWrap(t, "http://apa/bepa/", 5, "http:", "//apa", "/bepa", "/")
assertWrap(t, "http://apa/bepa/", 4, "http", "://a", "pa/", "bepa", "/")
assertWrap(t, "http://apa/bepa/", 3, "htt", "p:/", "/ap", "a/", "bep", "a/")
}
func TestWordWrapMarkdownLink(t *testing.T) {
assertWrap(t, "[something](http://apa/bepa)", 13, "[something]", "(http://apa/", "bepa)")
assertWrap(t, "[something](http://apa/bepa)", 12, "[something]", "(http://apa/", "bepa)")
assertWrap(t, "[something](http://apa/bepa)", 11, "[something]", "(http://apa", "/bepa)")
// This doesn't look great, room for tuning!
assertWrap(t, "[something](http://apa/bepa)", 10, "[something", "]", "(http://ap", "a/bepa)")
}
func TestWordWrapWideChars(t *testing.T) {
// The width is in cells, and there are wide chars in here using multiple cells.
assertWrap(t, "x上åˆy", 6, "x上åˆy")
assertWrap(t, "x上åˆy", 5, "x上åˆ", "y")
assertWrap(t, "x上åˆy", 4, "x上", "åˆy")
assertWrap(t, "x上åˆy", 3, "x上", "åˆy")
assertWrap(t, "x上åˆy", 2, "x", "上", "åˆ", "y")
}
func TestGetWrapCountWideChars(t *testing.T) {
line := tokenize("x上åˆy")
assert.Equal(t, getWrapCount(line, 5), 3)
assert.Equal(t, getWrapCount(line, 4), 2)
assert.Equal(t, getWrapCount(line, 3), 2)
assert.Equal(t, getWrapCount(line, 2), 1)
assert.Equal(t, getWrapCount(line, 1), 1)
}
func BenchmarkWrapLine(b *testing.B) {
log.SetLevel(log.WarnLevel) // Stop info logs from polluting benchmark output
words := "Here are some words of different lengths, some of which are very long, and some of which are short. "
lineLen := 60_000
line := ""
for len(line) < lineLen {
line += words
}
line = line[:lineLen]
styledRunes := tokenize(line)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = wrapLine(73, styledRunes)
}
b.StopTimer()
}
moor-2.10.3/internal/logwriter.go 0000664 0000000 0000000 00000000657 15135744745 0016761 0 ustar 00root root 0000000 0000000 package internal
import (
"strings"
"sync"
)
// Capture log lines and support returning all logged lines as one string
type LogWriter struct {
lock sync.Mutex
buffer strings.Builder
}
func (lw *LogWriter) Write(p []byte) (n int, err error) {
lw.lock.Lock()
defer lw.lock.Unlock()
return lw.buffer.Write(p)
}
func (lw *LogWriter) String() string {
lw.lock.Lock()
defer lw.lock.Unlock()
return lw.buffer.String()
}
moor-2.10.3/internal/pager-search.go 0000664 0000000 0000000 00000035561 15135744745 0017306 0 ustar 00root root 0000000 0000000 package internal
import (
"fmt"
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/internal/linemetadata"
)
// Scroll to the next search hit, while the user is typing the search string.
func (p *Pager) scrollToSearchHits() {
if p.search.Inactive() {
// This is not a search
return
}
if p.searchHitIsVisible() {
// Already on-screen
return
}
if p.scrollRightToSearchHits() {
// Found it to the right, done!
return
}
lineIndex := p.scrollPosition.lineIndex(p)
if lineIndex == nil {
// No lines to search
return
}
firstHitIndex := FindFirstHit(p.Reader(), p.search, *lineIndex, nil, SearchDirectionForward)
if firstHitIndex == nil {
alreadyAtTheTop := (*lineIndex == linemetadata.Index{})
if alreadyAtTheTop {
// No match, can't wrap, give up
return
}
// Try again from the top
firstHitIndex = FindFirstHit(p.Reader(), p.search, linemetadata.Index{}, lineIndex, SearchDirectionForward)
}
if firstHitIndex == nil {
// No match, give up
return
}
// Found a match on some line
p.scrollPosition = NewScrollPositionFromIndex(*firstHitIndex, "scrollToSearchHits")
p.leftColumnZeroBased = 0
p.showLineNumbers = p.ShowLineNumbers
if !p.searchHitIsVisible() {
p.scrollRightToSearchHits()
}
p.centerSearchHitsVertically()
}
// Scroll to the next search hit, when the user presses 'n'.
func (p *Pager) scrollToNextSearchHit() {
if p.search.Inactive() {
// Nothing to search for, never mind
return
}
if p.Reader().GetLineCount() == 0 {
// Nothing to search in, never mind
return
}
if p.scrollRightToSearchHits() {
// Found it to the right, done!
return
}
if p.isViewing() && p.isScrolledToEnd() {
p.mode = PagerModeNotFound{pager: p}
return
}
var firstSearchIndex linemetadata.Index
switch {
case p.isViewing():
// Start searching on the first line below the bottom of the screen
position := p.getLastVisiblePosition().NextLine(1)
firstSearchIndex = *position.lineIndex(p)
case p.isNotFound():
// Restart searching from the top
p.mode = PagerModeViewing{pager: p}
firstSearchIndex = linemetadata.Index{}
default:
panic(fmt.Sprint("Unknown search mode when finding next: ", p.mode))
}
firstHitIndex := FindFirstHit(p.Reader(), p.search, firstSearchIndex, nil, SearchDirectionForward)
if firstHitIndex == nil {
p.mode = PagerModeNotFound{pager: p}
return
}
p.scrollPosition = NewScrollPositionFromIndex(*firstHitIndex, "scrollToNextSearchHit")
// Don't let any search hit scroll out of sight
p.setTargetLine(nil)
p.leftColumnZeroBased = 0
p.showLineNumbers = p.ShowLineNumbers
if !p.searchHitIsVisible() {
p.scrollRightToSearchHits()
}
p.centerSearchHitsVertically()
}
// Scroll backwards to the previous search hit, while the user is typing the
// search string.
func (p *Pager) scrollToSearchHitsBackwards() {
if p.search.Inactive() {
// This is not a search
return
}
if p.searchHitIsVisible() {
// Already on-screen
return
}
if p.scrollLeftToSearchHits() {
// Found it to the left, done!
return
}
// Start at the top visible line
lineIndex := p.scrollPosition.lineIndex(p)
firstHitIndex := FindFirstHit(p.Reader(), p.search, *lineIndex, nil, SearchDirectionBackward)
if firstHitIndex == nil {
lastReaderLineIndex := linemetadata.IndexFromLength(p.Reader().GetLineCount())
if lastReaderLineIndex == nil {
// In the first part of the search we had some lines to search.
// Lines should never go away, so this should never happen.
log.Error("Wrapped backwards search had no lines to search")
return
}
lastVisibleLineIndex := p.getLastVisiblePosition().lineIndex(p)
canWrap := (*lineIndex != *lastVisibleLineIndex)
if !canWrap {
// No match, can't wrap, give up
return
}
// Try again from the bottom
firstHitIndex = FindFirstHit(p.Reader(), p.search, *lastReaderLineIndex, lineIndex, SearchDirectionBackward)
}
if firstHitIndex == nil {
// No match, give up
return
}
hitPosition := NewScrollPositionFromIndex(*firstHitIndex, "scrollToSearchHitsBackwards")
// Scroll so that the first hit is at the bottom of the screen. If the
// visible height is 1, we should scroll 0 steps.
p.scrollPosition = hitPosition.PreviousLine(p.visibleHeight() - 1)
p.scrollMaxRight()
if !p.searchHitIsVisible() {
p.scrollLeftToSearchHits()
}
p.centerSearchHitsVertically()
}
// Scroll backwards to the previous search hit, when the user presses 'N'.
func (p *Pager) scrollToPreviousSearchHit() {
if p.search.Inactive() {
// Nothing to search for, never mind
return
}
if p.Reader().GetLineCount() == 0 {
// Nothing to search in, never mind
return
}
if p.scrollLeftToSearchHits() {
// Found it to the left, done!
return
}
var firstSearchIndex linemetadata.Index
switch {
case p.isViewing():
if p.scrollPosition.lineIndex(p).Index() == 0 {
// Already at the top, can't go further up
p.mode = PagerModeNotFound{pager: p}
return
}
// Start searching on the first line above the top of the screen
position := p.scrollPosition.PreviousLine(1)
firstSearchIndex = *position.lineIndex(p)
case p.isNotFound():
// Restart searching from the bottom
p.mode = PagerModeViewing{pager: p}
firstSearchIndex = *linemetadata.IndexFromLength(p.Reader().GetLineCount())
default:
panic(fmt.Sprint("Unknown search mode when finding previous: ", p.mode))
}
hitIndex := FindFirstHit(p.Reader(), p.search, firstSearchIndex, nil, SearchDirectionBackward)
if hitIndex == nil {
p.mode = PagerModeNotFound{pager: p}
return
}
p.scrollPosition = *scrollPositionFromIndex("scrollToPreviousSearchHit", *hitIndex)
// Don't let any search hit scroll out of sight
p.setTargetLine(nil)
// Prefer hits to the right
p.scrollMaxRight()
if !p.searchHitIsVisible() {
p.scrollLeftToSearchHits()
}
p.centerSearchHitsVertically()
}
// Return true if any search hit is currently visible on screen.
//
// A search hit is considered visible if the first character of the hit is
// visible. This means that if the hit is longer than one character, the rest of
// it may be off-screen to the right. If that happens, the user can scroll right
// manually to see the rest of the hit.
func (p *Pager) searchHitIsVisible() bool {
for _, row := range p.renderLines().lines {
for _, cell := range row.cells {
if cell.StartsSearchHit {
// Found a search hit on screen!
return true
}
}
}
// No search hits found
return false
}
func (p *Pager) centerSearchHitsVertically() {
if p.WrapLongLines {
// FIXME: Centering is not supported when wrapping, future improvement!
return
}
for {
rendered := p.renderLines()
firstHitRow := -1
lastHitRow := -1
for rowIndex, row := range rendered.inputLines {
if !p.search.Matches(row.Plain()) {
continue
}
if firstHitRow == -1 {
firstHitRow = rowIndex
}
lastHitRow = rowIndex
}
if firstHitRow == -1 || lastHitRow == -1 {
log.Warn("No hits found while centering, how did we get here?")
return
}
// If the visible height is 1, the center screen row is 0.
centerScreenRowDoubled := p.visibleHeight() - 1
centerHitRowDoubled := firstHitRow + lastHitRow
// Divide by 2 here to get the amount of rows we need to scroll. We
// postponed the division by 2 until now to avoid rounding errors.
//
// If the center screen row is 1 (3 lines visible), and the center hit
// row is 2 (last screen line), we need to arrow down once.
deltaRows := (centerHitRowDoubled - centerScreenRowDoubled) / 2
newScrollPosition := p.scrollPosition.NextLine(deltaRows)
if p.ScrollPositionsEqual(p.scrollPosition, newScrollPosition) {
// No change, done!
return
}
p.scrollPosition = newScrollPosition
}
}
// If we are alredy too far right when you call this method, it will scroll
// left.
func (p *Pager) scrollMaxRight() {
if p.WrapLongLines {
// No horizontal scrolling when wrapping
return
}
// First, render a screen scrolled to the far left so we know how much space
// line numbers take.
p.leftColumnZeroBased = 0
p.showLineNumbers = p.ShowLineNumbers
rendered := p.renderLines()
// Find the widest line, in screen cells. Some runes are double-width.
widestLineWidth := 0
for _, inputLine := range rendered.inputLines {
lineLength := inputLine.DisplayWidth()
if lineLength > widestLineWidth {
widestLineWidth = lineLength
}
}
screenWidth, _ := p.screen.Size()
availableWidth := screenWidth - rendered.numberPrefixWidth
if widestLineWidth <= availableWidth {
// All lines fit on screen, this means we're now max scrolled right
return
}
p.showLineNumbers = false
availableWidth += rendered.numberPrefixWidth
if widestLineWidth <= availableWidth {
// All lines fit on screen with line numbers off, this means we're now
// max scrolled right
return
}
// If the line width is 10 and the available width is also 10, we should
// start at column 0.
p.leftColumnZeroBased = widestLineWidth - availableWidth
}
// Scroll right looking for search hits. Return true if we found any.
func (p *Pager) scrollRightToSearchHits() bool {
if p.WrapLongLines {
// No horizontal scrolling when wrapping
return false
}
restoreShowLineNumbers := p.showLineNumbers
restoreLeftColumn := p.leftColumnZeroBased
// Check how far right we can scroll at most. Factors involved:
// - Screen width
// - Length of longest visible line
screenWidth, _ := p.screen.Size()
widestLineWidth := 0 // In screen cells, some runes are double-width
rendered := p.renderLines()
for _, inputLine := range rendered.inputLines {
lineLength := inputLine.DisplayWidth()
if lineLength > widestLineWidth {
widestLineWidth = lineLength
}
}
// With a 10 wide screen and a 15 wide line (max index 14), the leftmost
// screen column can at most be 5:
//
// Screen column: 0123456789
// Line column: 5678901234
maxLeftmostColumn := widestLineWidth - screenWidth
// If we have line numbers and disable them, do any new hits appear?
if rendered.numberPrefixWidth > 0 {
// If the number prefix width is 4, and the screen width is 10, then 6 should be
// the first newly revealed column index (10-4):
//
// Screen column: 0123456789
// New cells: ______1234
//
// But since the rightmost column can be covered by scroll-right we need to subtract
// one more and get to 5.
firstJustRevealedColumn := screenWidth - rendered.numberPrefixWidth - 1
if firstJustRevealedColumn <= 0 {
log.Info("Screen too narrow ({}) to disable line numbers for search hits, skipping", screenWidth)
return false
}
p.showLineNumbers = false
for _, row := range p.renderLines().lines {
for column := firstJustRevealedColumn; column < len(row.cells); column++ {
if row.cells[column].StartsSearchHit {
// Found a search hit on screen!
return true
}
}
}
p.showLineNumbers = restoreShowLineNumbers
}
for p.leftColumnZeroBased < maxLeftmostColumn {
// FIXME: Rather than scrolling right one screen at a time, we should
// consider scanning all lines for search hits and scrolling directly to the
// first one that is off-screen to the right.
// If the screen width is 1, and we have no line numbers, the answer
// could be 1. But since the last column could be covered by scroll-right
// markers, we'll say 0.
firstNotVisibleColumn := p.leftColumnZeroBased + screenWidth - rendered.numberPrefixWidth - 1
if firstNotVisibleColumn < 1 {
log.Info("Screen is narrower than number prefix length, not scrolling right for search hits")
p.showLineNumbers = restoreShowLineNumbers
p.leftColumnZeroBased = restoreLeftColumn
return false
}
// Minus one to account for the scroll-left marker that will cover the
// first column after scrolling.
scrollToColumn := firstNotVisibleColumn - 1
p.showLineNumbers = false
p.leftColumnZeroBased = scrollToColumn
if p.searchHitIsVisible() {
// A new hit showed up!
if p.leftColumnZeroBased > maxLeftmostColumn {
// Scrolled beyond max, adjust
p.leftColumnZeroBased = maxLeftmostColumn
}
return true
}
}
// Can't scroll right, pretend nothing happened
p.showLineNumbers = restoreShowLineNumbers
p.leftColumnZeroBased = restoreLeftColumn
return false
}
// Scroll left looking for search hits. Return true if we found any.
func (p *Pager) scrollLeftToSearchHits() bool {
if p.WrapLongLines {
// No horizontal scrolling when wrapping
return false
}
restoreLeftColumn := p.leftColumnZeroBased
restoreShowLineNumbers := p.showLineNumbers
screenWidth, _ := p.screen.Size()
// If we go max left, which column will be the rightmost visible one?
var fullLeftRightmostVisibleColumn int
{
p.showLineNumbers = p.ShowLineNumbers
p.leftColumnZeroBased = 0
rendered := p.renderLines()
// If the screen width is 2, we have columns 0 and 1. The rightmost column can be covered by
// scroll-right markers, so the first not-visible column when fully scrolled left is 0, or
// "2 - 2".
fullLeftRightmostVisibleColumn = screenWidth - 2 - rendered.numberPrefixWidth
p.leftColumnZeroBased = restoreLeftColumn
p.showLineNumbers = restoreShowLineNumbers
}
if fullLeftRightmostVisibleColumn < 0 {
log.Info("Screen too narrow ({}) to scroll left for search hits, skipping", screenWidth)
return false
}
// Keep scrolling left until we either find a search hit, or reach the
// leftmost column with line numbers shown or not based on the user's
// preference.
for p.leftColumnZeroBased > 0 || (p.showLineNumbers != p.ShowLineNumbers) {
// FIXME: Rather than scrolling left one screen at a time, we should
// consider scanning all lines for search hits and scrolling directly to the
// first one that is off-screen to the left.
// Pretend the current leftmost column is not visible, since it could be
// covered by scroll-left markers.
lastNotVisibleColumn := p.leftColumnZeroBased
// Go left
if lastNotVisibleColumn <= fullLeftRightmostVisibleColumn {
// Going max left will show the column we want
p.showLineNumbers = p.ShowLineNumbers
p.leftColumnZeroBased = 0
} else {
// Scroll left one screen.
//
// If the screen width is 3, and we want column 5 to be visible, and
// there can be both scroll-left and scroll-right markers, we should
// start at colum 4 (covered by a scroll-left marker), so that
// column 5 is visible next to it.
//
// Set the leftmost column to 4, which is "5 - 3 + 2".
scrollToColumn := lastNotVisibleColumn - screenWidth + 2
if scrollToColumn < 0 {
scrollToColumn = 0
}
p.leftColumnZeroBased = scrollToColumn
// If showing line numbers was possible we should have ended up in
// the other if branch ^
p.showLineNumbers = false
}
if p.searchHitIsVisible() {
// Found it!
return true
}
}
// Scrolling left didn't find anything, pretend nothing happened
p.showLineNumbers = restoreShowLineNumbers
p.leftColumnZeroBased = restoreLeftColumn
return false
}
func (p *Pager) isViewing() bool {
_, isViewing := p.mode.(PagerModeViewing)
return isViewing
}
func (p *Pager) isNotFound() bool {
_, isNotFound := p.mode.(PagerModeNotFound)
return isNotFound
}
moor-2.10.3/internal/pager-search_test.go 0000664 0000000 0000000 00000026117 15135744745 0020342 0 ustar 00root root 0000000 0000000 package internal
import (
"strings"
"testing"
"github.com/walles/moor/v2/internal/reader"
"github.com/walles/moor/v2/twin"
"gotest.tools/v3/assert"
)
func TestScrollMaxRight_AllLinesFitWithLineNumbers(t *testing.T) {
// Case 2: All lines fit with line numbers
screenWidth := 20
widestLineWidth := 16 // Just below available width
line := strings.Repeat("x", widestLineWidth)
reader := reader.NewFromTextForTesting("test", line)
screen := twin.NewFakeScreen(screenWidth, 5)
pager := NewPager(reader)
pager.screen = screen
pager.ShowLineNumbers = true
pager.WrapLongLines = false
pager.scrollMaxRight()
assert.Equal(t, 0, pager.leftColumnZeroBased)
assert.Equal(t, true, pager.showLineNumbers)
}
func TestScrollMaxRight_AllLinesFitWithoutLineNumbers1(t *testing.T) {
// Case 2: All lines fit with line numbers
screenWidth := 20
widestLineWidth := 17 // Just above available width with line numbers
line := strings.Repeat("x", widestLineWidth)
reader := reader.NewFromTextForTesting("test", line)
screen := twin.NewFakeScreen(screenWidth, 5)
pager := NewPager(reader)
pager.screen = screen
pager.ShowLineNumbers = true
pager.WrapLongLines = false
pager.scrollMaxRight()
assert.Equal(t, 0, pager.leftColumnZeroBased)
assert.Equal(t, false, pager.showLineNumbers)
}
func TestScrollMaxRight_AllLinesFitWithoutLineNumbers2(t *testing.T) {
// Case 3: All lines fit only if line numbers are hidden, just at the edge
screenWidth := 20
widestLineWidth := 20 // Above available with line numbers, just below without
line := strings.Repeat("x", widestLineWidth)
reader := reader.NewFromTextForTesting("test", line)
screen := twin.NewFakeScreen(screenWidth, 5)
pager := NewPager(reader)
pager.screen = screen
pager.ShowLineNumbers = true
pager.WrapLongLines = false
pager.scrollMaxRight()
assert.Equal(t, 0, pager.leftColumnZeroBased)
assert.Equal(t, false, pager.showLineNumbers)
}
func TestScrollMaxRight_WidestLineExceedsScreenWidth_Edge(t *testing.T) {
// Case 4: Widest line just exceeds available width even without line numbers
screenWidth := 20
widestLineWidth := 21 // Just above available width without line numbers
line := strings.Repeat("x", widestLineWidth)
reader := reader.NewFromTextForTesting("test", line)
screen := twin.NewFakeScreen(screenWidth, 5)
pager := NewPager(reader)
pager.screen = screen
pager.ShowLineNumbers = true
pager.WrapLongLines = false
pager.scrollMaxRight()
assert.Equal(t, 1, pager.leftColumnZeroBased)
assert.Equal(t, false, pager.showLineNumbers)
}
func modeName(pager *Pager) string {
switch pager.mode.(type) {
case PagerModeViewing:
return "Viewing"
case PagerModeNotFound:
return "NotFound"
case *PagerModeSearch:
return "Search"
case *PagerModeGotoLine:
return "GotoLine"
default:
panic("Unknown pager mode")
}
}
// Create a pager with three screen lines reading from a six lines stream
func createThreeLinesPager(t *testing.T) *Pager {
reader := reader.NewFromTextForTesting("", "a\nb\nc\nd\ne\nf\n")
screen := twin.NewFakeScreen(20, 3)
pager := NewPager(reader)
pager.screen = screen
assert.Equal(t, "Viewing", modeName(pager), "Initial pager state")
return pager
}
func TestScrollToNextSearchHit_StartAtBottom(t *testing.T) {
// Create a pager scrolled to the last line
pager := createThreeLinesPager(t)
pager.scrollToEnd()
// Set the search to something that doesn't exist in this pager
pager.search.For("xxx")
// Scroll to the next search hit
pager.scrollToNextSearchHit()
assert.Equal(t, "NotFound", modeName(pager))
}
func TestScrollToNextSearchHit_StartAtTop(t *testing.T) {
// Create a pager scrolled to the first line
pager := createThreeLinesPager(t)
// Set the search to something that doesn't exist in this pager
pager.search.For("xxx")
// Scroll to the next search hit
pager.scrollToNextSearchHit()
assert.Equal(t, "NotFound", modeName(pager))
}
func TestScrollToNextSearchHit_WrapAfterNotFound(t *testing.T) {
// Create a pager scrolled to the last line
pager := createThreeLinesPager(t)
pager.scrollToEnd()
// Search for "a", it's on the first line (ref createThreeLinesPager())
pager.search.For("a")
// Scroll to the next search hit, this should take us into _NotFound
pager.scrollToNextSearchHit()
assert.Equal(t, "NotFound", modeName(pager))
// Scroll to the next search hit, this should wrap the search and take us to
// the top
pager.scrollToNextSearchHit()
assert.Equal(t, "Viewing", modeName(pager))
assert.Assert(t, pager.lineIndex().IsZero())
}
func TestScrollToNextSearchHit_WrapAfterFound(t *testing.T) {
// Create a pager scrolled to the last line
pager := createThreeLinesPager(t)
pager.scrollToEnd()
// Search for "f", it's on the last line (ref createThreeLinesPager())
pager.search.For("f")
// Scroll to the next search hit, this should take us into _NotFound
pager.scrollToNextSearchHit()
assert.Equal(t, "NotFound", modeName(pager))
// Scroll to the next search hit, this should wrap the search and take us
// back to the bottom again
pager.scrollToNextSearchHit()
assert.Equal(t, "Viewing", modeName(pager))
assert.Equal(t, 4, pager.lineIndex().Index())
}
// Ref: https://github.com/walles/moor/issues/152
func Test152(t *testing.T) {
// Show a pager on a five lines terminal
reader := reader.NewFromTextForTesting("", "a\nab\nabc\nabcd\nabcde\nabcdef\n")
screen := twin.NewFakeScreen(20, 5)
pager := NewPager(reader)
pager.screen = screen
assert.Equal(t, "Viewing", modeName(pager), "Initial pager state")
searchMode := NewPagerModeSearch(pager, SearchDirectionForward, pager.scrollPosition)
pager.mode = searchMode
// Search for the first not-visible hit
searchMode.inputBox.setText("abcde")
assert.Equal(t, "Search", modeName(pager))
assert.Equal(t, 2, pager.lineIndex().Index())
}
func TestScrollLeftToSearchHits_NoLineNumbers(t *testing.T) {
reader := reader.NewFromTextForTesting("", "a234567890")
screen := twin.NewFakeScreen(10, 5)
pager := NewPager(reader)
pager.screen = screen
pager.ShowLineNumbers = false
pager.showLineNumbers = false
pager.search.For("a")
pager.leftColumnZeroBased = 1
assert.Equal(t, true, pager.scrollLeftToSearchHits())
assert.Equal(t, 0, pager.leftColumnZeroBased)
assert.Equal(t, false, pager.showLineNumbers)
}
func TestScrollLeftToSearchHits_WithLineNumbers(t *testing.T) {
reader := reader.NewFromTextForTesting("", "a234567890")
screen := twin.NewFakeScreen(10, 5)
pager := NewPager(reader)
pager.screen = screen
pager.ShowLineNumbers = true
pager.showLineNumbers = false
pager.search.For("a")
pager.leftColumnZeroBased = 1
assert.Equal(t, true, pager.scrollLeftToSearchHits())
assert.Equal(t, 0, pager.leftColumnZeroBased)
assert.Equal(t, true, pager.showLineNumbers)
}
func TestScrollLeftToSearchHits_ScrollOneScreen(t *testing.T) {
reader := reader.NewFromTextForTesting("", "01234567890a234567890123456789")
screen := twin.NewFakeScreen(10, 5)
pager := NewPager(reader)
pager.screen = screen
pager.ShowLineNumbers = true
pager.showLineNumbers = false
pager.search.For("a")
pager.leftColumnZeroBased = 20
assert.Equal(t, true, pager.scrollLeftToSearchHits())
assert.Equal(t, 4, pager.leftColumnZeroBased,
"We started at 20, screen is 10 wide, each scroll moves 8 to compensate for scroll markers, and 20-8-8=4")
assert.Equal(t, false, pager.showLineNumbers)
}
// If the screen is too narrow for line numbers, there's no point in scrolling.
// This test has provoked some panics.
func TestScrollRightToSearchHits_NarrowScreen(t *testing.T) {
reader := reader.NewFromTextForTesting("", "abcdefg")
screen := twin.NewFakeScreen(1, 5)
pager := NewPager(reader)
pager.screen = screen
pager.showLineNumbers = false
assert.Equal(t, pager.scrollRightToSearchHits(), false)
pager.showLineNumbers = true
assert.Equal(t, pager.scrollRightToSearchHits(), false)
}
func TestScrollRightToSearchHits_DisableLineNumbersToSeeHit0(t *testing.T) {
reader := reader.NewFromTextForTesting("", "12345a")
screen := twin.NewFakeScreen(10, 5)
pager := NewPager(reader)
pager.screen = screen
pager.ShowLineNumbers = true
pager.showLineNumbers = true
pager.search.For("a")
pager.leftColumnZeroBased = 0
assert.Equal(t, true, pager.scrollRightToSearchHits())
assert.Equal(t, 0, pager.leftColumnZeroBased, "Should scroll right to bring 'a' into view")
assert.Equal(t, false, pager.showLineNumbers, "Should disable line numbers to fit search hit")
}
func TestScrollRightToSearchHits_DisableLineNumbersToSeeHit(t *testing.T) {
reader := reader.NewFromTextForTesting("", "123456789a")
screen := twin.NewFakeScreen(10, 5)
pager := NewPager(reader)
pager.screen = screen
pager.ShowLineNumbers = true
pager.showLineNumbers = true
pager.search.For("a")
pager.leftColumnZeroBased = 0
assert.Equal(t, true, pager.scrollRightToSearchHits())
assert.Equal(t, 0, pager.leftColumnZeroBased, "Should scroll right to bring 'a' into view")
assert.Equal(t, false, pager.showLineNumbers, "Should disable line numbers to fit search hit")
}
func TestScrollRightToSearchHits_HiddenByScrollMarker(t *testing.T) {
reader := reader.NewFromTextForTesting("", "123456789a234567890")
screen := twin.NewFakeScreen(10, 5)
pager := NewPager(reader)
pager.screen = screen
pager.ShowLineNumbers = false
pager.showLineNumbers = false
pager.search.For("a")
pager.leftColumnZeroBased = 0
assert.Equal(t, true, pager.scrollRightToSearchHits())
assert.Equal(t, 8, pager.leftColumnZeroBased, "Should scroll right to bring 'a' into view from behind scroll marker")
}
// Repro case for https://github.com/walles/moor/issues/337.
func TestScrollRightToSearchHits_Issue337(t *testing.T) {
reader := reader.NewFromTextForTesting("", "123456a89012345")
screen := twin.NewFakeScreen(10, 5)
pager := NewPager(reader)
pager.screen = screen
pager.ShowLineNumbers = false
pager.showLineNumbers = false
pager.search.For("a")
pager.leftColumnZeroBased = 0
assert.Equal(t, false, pager.scrollRightToSearchHits(), "Search hit was already visible, should not have scrolled")
assert.Equal(t, 0, pager.leftColumnZeroBased, "Should not have scrolled")
}
func TestScrollRightToSearchHits_LastCharHit(t *testing.T) {
const line = "x0123456789a"
reader := reader.NewFromTextForTesting("", line)
screen := twin.NewFakeScreen(10, 5)
pager := NewPager(reader)
pager.screen = screen
pager.ShowLineNumbers = false
pager.showLineNumbers = false
pager.search.For("a")
pager.leftColumnZeroBased = 0
assert.Equal(t, true, pager.scrollRightToSearchHits())
width, _ := screen.Size()
lastCol := pager.leftColumnZeroBased + width - 1
assert.Equal(t, strings.Index(line, "a"), lastCol, "Search hit should be in the last screen column")
}
func TestScrollRightToSearchHits_OnlyStartOfHitTriggers(t *testing.T) {
// Arrange: create a line with a multi-rune search hit
line := "abcDEFGHIJKLMNOPQRSTUVWXYZ"
readerImpl := reader.NewFromTextForTesting("test", line)
screen := twin.NewFakeScreen(5, 2) // Narrow screen to force scrolling
pager := NewPager(readerImpl)
pager.search.For("DEFGHIJ") // Match starts at index 3
pager.screen = screen
pager.WrapLongLines = false
pager.ShowLineNumbers = false
pager.showLineNumbers = false
assert.Assert(t, !pager.scrollRightToSearchHits(), "No more search hit starts to the right, should not scroll")
}
moor-2.10.3/internal/pager.go 0000664 0000000 0000000 00000053252 15135744745 0016040 0 ustar 00root root 0000000 0000000 package internal
import (
"fmt"
"math"
"runtime/debug"
"sync"
"time"
"github.com/alecthomas/chroma/v2"
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/reader"
"github.com/walles/moor/v2/internal/search"
"github.com/walles/moor/v2/internal/textstyles"
"github.com/walles/moor/v2/twin"
)
type PagerMode interface {
onKey(key twin.KeyCode)
onRune(char rune)
drawFooter(filenameText string, statusText string, spinner string)
}
type StatusBarOption int
const (
//revive:disable-next-line:var-naming
STATUSBAR_STYLE_INVERSE StatusBarOption = iota
//revive:disable-next-line:var-naming
STATUSBAR_STYLE_PLAIN
//revive:disable-next-line:var-naming
STATUSBAR_STYLE_BOLD
)
type eventSpinnerUpdate struct {
spinner string
}
type eventMoreLinesAvailable struct{}
// Either reading, highlighting or both are done. Check reader.Done() and
// reader.HighlightingDone() for details.
type eventMaybeDone struct{}
// Pager is the main on-screen pager
type Pager struct {
readers []*reader.ReaderImpl // Immutable since startup, no locking needed
currentReader int // Index into the readers slice
readerLock sync.Mutex // Protects currentReader
readerSwitched chan struct{}
// A view of the current reader, possibly filtered
filteringReader FilteringReader
screen twin.Screen
quit bool
scrollPosition scrollPosition
leftColumnZeroBased int
// Maybe this should be renamed to "controller"? Because it controls the UI?
// But since we replace it in a lot of places based on the UI mode, maybe
// mode is better?
mode PagerMode
search search.Search
// This should never be null while paging. Configured in NewPager().
searchHistory *SearchHistory
filter search.Search
// We used to have a "Following" field here. If you want to follow, set
// TargetLineNumber to linemetadata.IndexMax() instead, see below.
isShowingHelp bool
preHelpState *_PreHelpState
// User preference
ShowLineNumbers bool
// Current state, initialized in StartPaging()
showLineNumbers bool
StatusBarStyle StatusBarOption
ShowStatusBar bool
UnprintableStyle textstyles.UnprintableStyleT
WrapLongLines bool
// Ref: https://github.com/walles/moor/issues/113
QuitIfOneScreen bool
// Ref: https://github.com/walles/moor/issues/94
ScrollLeftHint textstyles.CellWithMetadata
ScrollRightHint textstyles.CellWithMetadata
SideScrollAmount int // Left / right arrow keys scroll amount
TabSize int // Number of spaces per tab, default 8, should be positive
// If non-nil, scroll to this line as soon as possible. Set this value to
// IndexMax() to follow the end of the input (tail).
//
// NOTE: Always use setTargetLine() to keep the reader in sync with the
// pager!
TargetLine *linemetadata.Index
// If true, pager will clear the screen on return. If false, pager will
// clear the last line, and show the cursor.
DeInit bool
// If DeInit is false, leave this number of lines for the shell prompt after
// exiting
DeInitFalseMargin int
WithTerminalFg bool // If true, don't set linePrefix
// If false, don't highlight lines with search hits (but still highlight the
// actual hits)
WithSearchHitLineBackground bool
// Length of the longest line displayed. This is used for limiting scrolling
// to the right.
longestLineLength int
// Bookmarks that you can come back to.
//
// Ref: https://github.com/walles/moor/issues/175
bookmarks map[rune]scrollPosition
AfterExit func() error
}
type _PreHelpState struct {
scrollPosition scrollPosition
leftColumnZeroBased int
targetLine *linemetadata.Index
}
var _HelpReader = reader.NewFromTextForTesting("Help", `
Welcome to Moor, the nice pager!
Miscellaneous
-------------
* Press 'q' or 'ESC' to quit
* Press 'w' to toggle wrapping of long lines
* Press '=' to toggle showing the status bar at the bottom
* Press 'v' to edit the file in your favorite editor
* Press CTRL-t to change the tab size
Moving around
-------------
* Arrow keys
* Alt key plus left / right arrow steps one column at a time
* Left / right can be used to hide / show line numbers
* Home and End for start / end of the document
* 'g' for going to a specific line number
* 'm' sets a mark, you will be asked for a letter to label it with
* ' (single quote) jumps to the mark
* CTRL-p moves to the previous line
* CTRL-n moves to the next line
* PageUp / 'b' and PageDown / 'f'
* SPACE moves down a page
* < / 'gg' to go to the start of the document
* > / 'G' to go to the end of the document
* Half page 'u'p / 'd'own, or CTRL-u / CTRL-d
* CTRL-a moves to the leftmost position
* RETURN moves down one line
Switching files (if you opened multiple files)
----------------------------------------------
* Press ':' to enter file switching mode
Filtering
---------
Type '&' to start filtering, then type your filter expression.
While filtering, arrow keys, PageUp, PageDown, Home and End work as usual.
Press 'ESC' or RETURN to exit filtering mode.
Searching
---------
* Type / to start searching, then type what you want to find
* Type ? to search backwards, then type what you want to find
* Type RETURN to stop searching, or ESC to skip back to where the search started
* Press up / down arrows while searching to access search history
* Find next by typing 'n' (for "next")
* Find previous by typing SHIFT-N or 'p' (for "previous")
* Search is case sensitive if it contains any UPPER CASE CHARACTERS
* Search is interpreted as a regexp if it is a valid one
Reporting bugs
--------------
File issues at https://github.com/walles/moor/issues, or post
questions to johan.walles@gmail.com.
Installing Moor as your default pager
-------------------------------------
Put the following line in your ~/.bashrc, ~/.bash_profile or ~/.zshrc:
export PAGER=moor
Source Code
-----------
Available at https://github.com/walles/moor/.
`)
// NewPager creates a new Pager with default settings
func NewPager(readers ...*reader.ReaderImpl) *Pager {
if len(readers) == 0 {
panic("NewPager() needs at least one reader")
}
var name string
if readers[0] == nil || readers[0].DisplayName == nil || len(*readers[0].DisplayName) == 0 {
name = "Pager"
} else {
name = "Pager " + *readers[0].DisplayName
}
pager := Pager{
readers: readers,
currentReader: 0,
readerSwitched: make(chan struct{}, 1),
quit: false,
ShowLineNumbers: true, // Constant throghout the lifetime of the pager
showLineNumbers: true, // Will be updated over time
ShowStatusBar: true,
DeInit: true,
SideScrollAmount: 16,
TabSize: 8, // This is what less defaults to
ScrollLeftHint: textstyles.CellWithMetadata{Rune: '<', Style: twin.StyleDefault.WithAttr(twin.AttrReverse)},
ScrollRightHint: textstyles.CellWithMetadata{Rune: '>', Style: twin.StyleDefault.WithAttr(twin.AttrReverse)},
scrollPosition: newScrollPosition(name),
WithSearchHitLineBackground: true,
}
pager.mode = PagerModeViewing{pager: &pager}
pager.filteringReader = FilteringReader{
BackingReader: readers[0], // Always start with the first reader
Filter: &pager.filter,
}
searchHistory := BootSearchHistory("")
pager.searchHistory = &searchHistory
return &pager
}
// How many lines are visible on screen? Depends on screen height and whether or
// not the status bar is visible.
func (p *Pager) visibleHeight() int {
_, height := p.screen.Size()
// Only the viewing mode can be without status bar
hasStatusBar := p.ShowStatusBar || !p.isViewing()
if hasStatusBar {
return height - 1
}
return height
}
// How many cells are needed for this line number? Includes padding.
//
// Returns 0 if line numbers are disabled.
func (p *Pager) getLineNumberPrefixLength(lineNumber linemetadata.Number) int {
if !p.showLineNumbers {
return 0
}
length := len(lineNumber.Format()) + 1 // +1 for the space after the line number
if length < 4 {
// 4 = space for 3 digits followed by one whitespace
//
// https://github.com/walles/moor/issues/38
return 4
}
return length
}
// Single quoted parts of the help text will be highlighted relative to the
// status bar style.
func renderHelpText(help string) []twin.StyledRune {
var result []twin.StyledRune
shortcutHighlight := twin.AttrBold
if statusbarStyle.HasAttr(shortcutHighlight) {
shortcutHighlight = twin.AttrUnderline
}
if statusbarStyle.HasAttr(shortcutHighlight) {
shortcutHighlight = twin.AttrReverse
}
style := statusbarStyle
for _, token := range help {
if token == '\'' {
// Highlight things within single quotes
if style == statusbarStyle {
style = statusbarStyle.WithAttr(shortcutHighlight)
} else {
style = statusbarStyle
}
continue
}
result = append(result, twin.NewStyledRune(token, style))
}
return result
}
// Draw the footer string at the bottom using the status bar style.
//
// Single quoted parts of the help text will be bolded.
//
// prefix example value: "[1/3] "
// filename example value: "file.txt"
// status example value: ": 123 lines 0%"
// help example value: "Press 'h' for help, 'q' to quit"
func (p *Pager) setFooter(prefix string, filename string, status string, help string) {
width, height := p.screen.Size()
pos := 0
// Prefix (multiple open files)
for _, token := range prefix {
pos += p.screen.SetCell(pos, height-1, twin.NewStyledRune(token, statusbarStyle))
}
// File name
for _, token := range filename {
pos += p.screen.SetCell(pos, height-1, twin.NewStyledRune(token, statusbarFileStyle))
}
// percentage,
for _, token := range status + " " {
pos += p.screen.SetCell(pos, height-1, twin.NewStyledRune(token, statusbarStyle))
}
// Help text, highlight keyboard shortcuts
for _, cell := range renderHelpText(help) {
pos += p.screen.SetCell(pos, height-1, cell)
}
for pos < width {
pos += p.screen.SetCell(pos, height-1, twin.NewStyledRune(' ', statusbarStyle))
}
}
// Quit leaves the help screen or quits the pager
func (p *Pager) Quit() {
if !p.isShowingHelp {
p.quit = true
return
}
// Reset help
p.isShowingHelp = false
p.scrollPosition = p.preHelpState.scrollPosition
p.leftColumnZeroBased = p.preHelpState.leftColumnZeroBased
p.setTargetLine(p.preHelpState.targetLine)
p.preHelpState = nil
}
// Negative deltas move left instead
func (p *Pager) moveRight(delta int) {
if p.showLineNumbers && delta > 0 {
p.showLineNumbers = false
return
}
if p.leftColumnZeroBased == 0 && delta < 0 {
p.showLineNumbers = true
return
}
result := p.leftColumnZeroBased + delta
if result < 0 {
p.leftColumnZeroBased = 0
} else {
p.leftColumnZeroBased = result
}
// If we try to move past the characters when moving right, stop scrolling to
// avoid moving infinitely into the void.
if p.leftColumnZeroBased > p.longestLineLength {
p.leftColumnZeroBased = p.longestLineLength
}
}
func (p *Pager) Reader() reader.Reader {
if p.isShowingHelp {
return _HelpReader
}
return &p.filteringReader
}
func (p *Pager) handleScrolledUp() {
p.setTargetLine(nil)
}
func (p *Pager) handleScrolledDown() {
if p.isScrolledToEnd() {
// Follow output
reallyHigh := linemetadata.IndexMax()
p.setTargetLine(&reallyHigh)
} else {
p.setTargetLine(nil)
}
}
func (p *Pager) handleMoreLinesAvailable() {
// Without the isViewing() check, following will continue while
// searching, and I prefer it to stop so people can see what they
// are searching in.
if !p.isViewing() || p.TargetLine == nil {
return
}
// The user wants to scroll down to a specific line number
lineCount := p.Reader().GetLineCount()
if lineCount == 0 {
// No lines yet, keep waiting
return
}
if linemetadata.IndexFromLength(lineCount).IsBefore(*p.TargetLine) {
// Not there yet, keep scrolling
p.scrollToEnd()
return
}
// We see the target, scroll to it
p.scrollPosition = NewScrollPositionFromIndex(*p.TargetLine, "goToTargetLine")
p.setTargetLine(nil)
}
// Except for setting TargetLine, this method also syncs with the reader so that
// the reader knows how many lines it needs to fetch.
func (p *Pager) setTargetLine(targetLine *linemetadata.Index) {
p.readerLock.Lock()
r := p.readers[p.currentReader]
p.readerLock.Unlock()
log.Trace("Pager: Setting target line to ", targetLine, "...")
p.TargetLine = targetLine
if targetLine == nil {
// No target, just do your thing
r.SetPauseAfterLines(reader.DEFAULT_PAUSE_AFTER_LINES)
return
}
// Set the target with some lookahead to avoid fetching too few lines.
targetValue := targetLine.Index() + reader.DEFAULT_PAUSE_AFTER_LINES/2
if targetValue < targetLine.Index() {
// Overflow detected, clip to max int
targetValue = math.MaxInt
}
if targetValue < reader.DEFAULT_PAUSE_AFTER_LINES {
targetValue = reader.DEFAULT_PAUSE_AFTER_LINES
}
r.SetPauseAfterLines(targetValue)
}
// StartPaging brings up the pager on screen
func (p *Pager) StartPaging(screen twin.Screen, chromaStyle *chroma.Style, chromaFormatter *chroma.Formatter) {
log.Info("Pager starting")
defer func() {
p.readerLock.Lock()
r := p.readers[p.currentReader]
p.readerLock.Unlock()
if r.Err != nil {
log.Warnf("Reader reported an error: %s", r.Err.Error())
}
}()
p.showLineNumbers = p.ShowLineNumbers
textstyles.UnprintableStyle = p.UnprintableStyle
if p.TabSize > 0 {
// "0" = unset, stay at the default. If the tab size is negative, just
// ignoring it seems like the right move.
textstyles.TabSize = p.TabSize
}
consumeLessTermcapEnvs(screen.TerminalBackground(), chromaStyle, chromaFormatter)
styleUI(screen.TerminalBackground(), chromaStyle, chromaFormatter, p.StatusBarStyle, p.WithTerminalFg, p.WithSearchHitLineBackground)
p.screen = screen
p.mode = PagerModeViewing{pager: p}
p.bookmarks = make(map[rune]scrollPosition)
// Make sure the reader knows how many lines we want
p.setTargetLine(p.TargetLine)
go func() {
defer func() {
PanicHandler("StartPaging()/goroutine", recover(), debug.Stack())
}()
spinnerFrames := [...]string{"/.\\", "-o-", "\\O/", "| |"}
spinnerIndex := 0
spinnerTicker := time.NewTicker(200 * time.Millisecond)
lastSpinnerFrame := "UNSET" // Track the last spinner frame to avoid unnecessary redraws
// Support throttling of more-lines-available reads, see below
p.readerLock.Lock()
throttledMoreLines := p.readers[p.currentReader].MoreLinesAdded
p.readerLock.Unlock()
var reenable <-chan time.Time
for {
p.readerLock.Lock()
r := p.readers[p.currentReader]
p.readerLock.Unlock()
select {
case <-p.readerSwitched:
// A different reader is now active
p.filter = search.Search{}
p.readerLock.Lock()
r = p.readers[p.currentReader]
p.filteringReader.SetBackingReader(r)
p.readerLock.Unlock()
// Look in the right place for more lines
throttledMoreLines = r.MoreLinesAdded
reenable = nil
// Reset spinner for new reader so that we show it again if needed
lastSpinnerFrame = "UNSET"
// Tell the viewer to replace the view
screen.Events() <- eventMoreLinesAvailable{}
case <-throttledMoreLines:
screen.Events() <- eventMoreLinesAvailable{}
// Disable further receives for 200ms. This avoids flooding the
// event loop if a lot of lines are added in a short time.
throttledMoreLines = nil
reenable = time.After(200 * time.Millisecond)
case <-reenable:
// Re-enable channel
throttledMoreLines = r.MoreLinesAdded
reenable = nil
case <-spinnerTicker.C:
currentSpinnerFrame := spinnerFrames[spinnerIndex]
if r.ReadingDone.Load() {
// We're done, clear the spinner
currentSpinnerFrame = ""
}
spinnerIndex++
if spinnerIndex >= len(spinnerFrames) {
spinnerIndex = 0
}
if currentSpinnerFrame == lastSpinnerFrame {
// Prevent unnecessary redraws
continue
}
screen.Events() <- eventSpinnerUpdate{currentSpinnerFrame}
lastSpinnerFrame = currentSpinnerFrame
case <-r.MaybeDone:
screen.Events() <- eventMaybeDone{}
}
}
}()
log.Info("Entering pager main loop...")
// Main loop
spinner := ""
for !p.quit {
if len(screen.Events()) == 0 {
// Nothing more to process for now, redraw the screen
p.redraw(spinner)
p.readerLock.Lock()
r := p.readers[p.currentReader]
p.readerLock.Unlock()
// Ref:
// https://github.com/gwsw/less/blob/ff8869aa0485f7188d942723c9fb50afb1892e62/command.c#L828-L831
//
// Note that we do the slow (atomic) checks only if the fast ones
// (no locking required) passed.
//
// Also, we only do this if we have exactly one reader, because
// that's what less does.
if len(p.readers) == 1 && p.QuitIfOneScreen && !p.isShowingHelp && r.ReadingDone.Load() && r.HighlightingDone.Load() {
if p.fitsOnOneScreen() {
// Ref:
// https://github.com/walles/moor/issues/113#issuecomment-1368294132
p.showLineNumbers = false // Requires a redraw to take effect, see below
p.DeInit = false
p.quit = true
// Without this the line numbers setting ^ won't take effect
p.redraw(spinner)
log.Info("Exiting because of --quit-if-one-screen, everything fit on one screen and we're done")
break
}
}
}
event := <-screen.Events()
switch event := event.(type) {
case twin.EventKeyCode:
log.Tracef("Handling key event %d...", event.KeyCode())
p.mode.onKey(event.KeyCode())
case twin.EventRune:
log.Tracef("Handling rune event '%c'/0x%04x...", event.Rune(), event.Rune())
p.mode.onRune(event.Rune())
case twin.EventMouse:
log.Tracef("Handling mouse event %d...", event.Buttons())
switch event.Buttons() {
case twin.MouseWheelUp:
// Clipping is done in _Redraw()
p.scrollPosition = p.scrollPosition.PreviousLine(1)
case twin.MouseWheelDown:
// Clipping is done in _Redraw()
p.scrollPosition = p.scrollPosition.NextLine(1)
case twin.MouseWheelLeft:
p.moveRight(-p.SideScrollAmount)
case twin.MouseWheelRight:
p.moveRight(p.SideScrollAmount)
}
case twin.EventResize:
// We'll be implicitly redrawn just by taking another lap in the loop
case twin.EventExit:
log.Info("Got a Twin exit event, exiting")
return
case eventMoreLinesAvailable:
p.handleMoreLinesAvailable()
case eventMaybeDone:
// Man pages come pre-formatted for the screen width, and line
// numbers will mess that up. So we disable line numbers if we
// detect a man page by its contents.
//
// See also noLineNumbersDefault() where we use environment
// variables to try to detect man paging.
if p.haveLoadedManPage() && len(p.readers) == 1 {
p.ShowLineNumbers = false
p.showLineNumbers = false
log.Info("man page detected by contents, disabling line numbers")
}
case eventSpinnerUpdate:
spinner = event.spinner
default:
log.Warnf("Unhandled event type: %v", event)
}
}
log.Info("Pager main loop done")
}
// The height parameter is the terminal height minus the height of the user's
// shell prompt.
//
// This way nothing gets scrolled off screen after we exit.
func (p *Pager) fitsOnOneScreenWrapped() bool {
if len(p.readers) != 1 {
// At most one screen will fit on one screen...
return false
}
// Create a fake screen of height + 1 lines
width, height := p.screen.Size()
// If the screen height is one, and the prompt height is zero, then the last
// line number will be zero. But since we want one extra line to check for
// overflow, we now want the last line number to be one.
//
// So if the initial height is 1, we want the last line number to be 1.
// Which is what we get from here.
lastScreenRow := height - p.DeInitFalseMargin
// If the last screen row is supposed to be one, we need to set the height
// to two. So we add one here.
testScreenHeight := lastScreenRow + 1
testScreen := twin.NewFakeScreen(width, testScreenHeight)
// Create a fake pager for that screen, with no status bar, and matching
// line number settings + tab size and wrap settings
p.readerLock.Lock()
fakePager := NewPager(p.readers[0])
p.readerLock.Unlock()
fakePager.screen = testScreen
// If we drop out because of quit-if-one-screen, we will not print any line numbers
fakePager.showLineNumbers = false
fakePager.WrapLongLines = p.WrapLongLines
fakePager.ShowStatusBar = false // We are only interested in content lines
fakePager.TabSize = p.TabSize
// Render on our test screen
rendered := fakePager.renderLines()
return len(rendered.lines) < testScreenHeight
}
func (p *Pager) fitsOnOneScreen() bool {
if len(p.readers) != 1 {
// At most one screen will fit on one screen...
return false
}
if p.WrapLongLines {
return p.fitsOnOneScreenWrapped()
}
width, height := p.screen.Size()
height -= p.DeInitFalseMargin
p.readerLock.Lock()
reader := p.readers[0]
p.readerLock.Unlock()
if reader.GetLineCount() > height {
return false
}
lines := reader.GetLines(linemetadata.Index{}, reader.GetLineCount())
for _, line := range lines.Lines {
rendered := line.HighlightedTokens(twin.StyleDefault, twin.StyleDefault, search.Search{}, width+1).StyledRunes
if len(rendered) > width {
// This line is too long to fit on one screen line, no fit
return false
}
}
return true
}
// After the pager has exited and the normal screen has been restored, you can
// call this method to print the pager contents to screen again, faking
// "leaving" pager contents on screen after exit.
func (p *Pager) ReprintAfterExit() {
// Figure out how many screen lines are used by pager contents
renderedScreen := p.renderLines()
screenLinesCount := len(renderedScreen.lines)
_, screenHeight := p.screen.Size()
screenHeightWithoutFooter := screenHeight - p.DeInitFalseMargin
if screenLinesCount > screenHeightWithoutFooter {
screenLinesCount = screenHeightWithoutFooter
}
if screenLinesCount > 0 {
p.screen.ShowNLines(screenLinesCount)
fmt.Println()
}
}
moor-2.10.3/internal/pager_test.go 0000664 0000000 0000000 00000054771 15135744745 0017106 0 ustar 00root root 0000000 0000000 package internal
import (
"fmt"
"os"
"path"
"runtime"
"strings"
"testing"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/styles"
"github.com/google/go-cmp/cmp"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/reader"
"github.com/walles/moor/v2/internal/textstyles"
"github.com/walles/moor/v2/twin"
"gotest.tools/v3/assert"
)
// NOTE: You can find related tests in screenLines_test.go.
const blueBackgroundClearToEol0 = "\x1b[44m\x1b[0K" // With 0 before the K, should clear to EOL
const blueBackgroundClearToEol = "\x1b[44m\x1b[K" // No 0 before the K, should also clear to EOL
const samplesDir = "../sample-files"
func TestUnicodeRendering(t *testing.T) {
reader := reader.NewFromTextForTesting("", "åäö")
var answers = []twin.StyledRune{
twin.NewStyledRune('Ã¥', twin.StyleDefault),
twin.NewStyledRune('ä', twin.StyleDefault),
twin.NewStyledRune('ö', twin.StyleDefault),
}
contents := startPaging(t, reader).GetRow(0)
for pos, expected := range answers {
assertRunesEqual(t, expected, contents[pos])
}
}
func assertRunesEqual(t *testing.T, expected twin.StyledRune, actual twin.StyledRune) {
t.Helper()
if actual.Rune == expected.Rune && actual.Style == expected.Style {
return
}
t.Errorf("Expected %v, got %v", expected, actual)
}
func TestFgColorRendering(t *testing.T) {
reader := reader.NewFromTextForTesting("",
"\x1b[30ma\x1b[31mb\x1b[32mc\x1b[33md\x1b[34me\x1b[35mf\x1b[36mg\x1b[37mh\x1b[0mi")
var answers = []twin.StyledRune{
twin.NewStyledRune('a', twin.StyleDefault.WithForeground(twin.NewColor16(0))),
twin.NewStyledRune('b', twin.StyleDefault.WithForeground(twin.NewColor16(1))),
twin.NewStyledRune('c', twin.StyleDefault.WithForeground(twin.NewColor16(2))),
twin.NewStyledRune('d', twin.StyleDefault.WithForeground(twin.NewColor16(3))),
twin.NewStyledRune('e', twin.StyleDefault.WithForeground(twin.NewColor16(4))),
twin.NewStyledRune('f', twin.StyleDefault.WithForeground(twin.NewColor16(5))),
twin.NewStyledRune('g', twin.StyleDefault.WithForeground(twin.NewColor16(6))),
twin.NewStyledRune('h', twin.StyleDefault.WithForeground(twin.NewColor16(7))),
twin.NewStyledRune('i', twin.StyleDefault),
}
contents := startPaging(t, reader).GetRow(0)
for pos, expected := range answers {
assertRunesEqual(t, expected, contents[pos])
}
}
func TestPageEmpty(t *testing.T) {
// "---" is the eofSpinner of pager.go
assert.Equal(t, "---", renderTextLine(""))
}
func TestBrokenUtf8(t *testing.T) {
// The broken UTF8 character in the middle is based on "©" = 0xc2a9
reader := reader.NewFromTextForTesting("", "abc\xc2def")
var answers = []twin.StyledRune{
twin.NewStyledRune('a', twin.StyleDefault),
twin.NewStyledRune('b', twin.StyleDefault),
twin.NewStyledRune('c', twin.StyleDefault),
twin.NewStyledRune('?', twin.StyleDefault.WithForeground(twin.NewColor16(7)).WithBackground(twin.NewColor16(1))),
twin.NewStyledRune('d', twin.StyleDefault),
twin.NewStyledRune('e', twin.StyleDefault),
twin.NewStyledRune('f', twin.StyleDefault),
}
contents := startPaging(t, reader).GetRow(0)
for pos, expected := range answers {
assertRunesEqual(t, expected, contents[pos])
}
}
func startPaging(t *testing.T, reader *reader.ReaderImpl) *twin.FakeScreen {
// 0 means default tab size. Defaults to 8 to be like less.
return startPagingWithTabSizeAndScreen(t, 0, twin.NewFakeScreen(20, 10), reader)
}
func startPagingWithTabSize(t *testing.T, tabSize int, reader *reader.ReaderImpl) *twin.FakeScreen {
return startPagingWithTabSizeAndScreen(t, tabSize, twin.NewFakeScreen(20, 10), reader)
}
func startPagingWithScreen(t *testing.T, screen *twin.FakeScreen, reader *reader.ReaderImpl) *twin.FakeScreen {
// 0 means default tab size. Defaults to 8 to be like less.
return startPagingWithTabSizeAndScreen(t, 0, screen, reader)
}
func startPagingWithTabSizeAndScreen(t *testing.T, tabSize int, screen *twin.FakeScreen, reader *reader.ReaderImpl) *twin.FakeScreen {
err := reader.Wait()
if err != nil {
t.Fatalf("Failed waiting for reader: %v", err)
}
pager := NewPager(reader)
pager.TabSize = tabSize
pager.ShowLineNumbers = false
pager.showLineNumbers = false
// Tell our Pager to quit immediately
pager.Quit()
// Except for just quitting, this also associates our FakeScreen with the Pager
pager.StartPaging(screen, nil, nil)
// This makes sure at least one frame gets rendered
pager.redraw("")
return screen
}
// Set style to "native" and use the TTY16m formatter
func startPagingWithTerminalFg(t *testing.T, reader *reader.ReaderImpl, withTerminalFg bool) *twin.FakeScreen {
err := reader.Wait()
if err != nil {
t.Fatalf("Failed waiting for reader: %v", err)
}
screen := twin.NewFakeScreen(20, 10)
pager := NewPager(reader)
pager.ShowLineNumbers = false
pager.showLineNumbers = false
pager.WithTerminalFg = withTerminalFg
// Tell our Pager to quit immediately
pager.Quit()
// Except for just quitting, this also associates our FakeScreen with the Pager
pager.StartPaging(screen, styles.Get("native"), &formatters.TTY16m)
// This makes sure at least one frame gets rendered
pager.redraw("")
return screen
}
// assertIndexOfFirstX verifies the (zero-based) index of the first 'x'
func assertIndexOfFirstX(t *testing.T, tabSize int, s string, expectedIndex int) {
reader := reader.NewFromTextForTesting("", s)
contents := startPagingWithTabSize(t, tabSize, reader).GetRow(0)
for pos, cell := range contents {
if cell.Rune != 'x' {
continue
}
if pos == expectedIndex {
// Success!
return
}
t.Errorf("Expected first 'x' with tab size %d to be at (zero-based) index %d, but was at %d: \"%s\"",
tabSize, expectedIndex, pos, strings.ReplaceAll(s, "\x09", ""))
return
}
panic("No 'x' found")
}
func TestTabHandling(t *testing.T) {
assertIndexOfFirstX(t, 4, "x", 0)
assertIndexOfFirstX(t, 4, "\x09x", 4)
assertIndexOfFirstX(t, 4, "\x09\x09x", 8)
assertIndexOfFirstX(t, 4, "J\x09x", 4)
assertIndexOfFirstX(t, 4, "Jo\x09x", 4)
assertIndexOfFirstX(t, 4, "Joh\x09x", 4)
assertIndexOfFirstX(t, 4, "Joha\x09x", 8)
assertIndexOfFirstX(t, 4, "Johan\x09x", 8)
assertIndexOfFirstX(t, 4, "\x09J\x09x", 8)
assertIndexOfFirstX(t, 4, "\x09Jo\x09x", 8)
assertIndexOfFirstX(t, 4, "\x09Joh\x09x", 8)
assertIndexOfFirstX(t, 4, "\x09Joha\x09x", 12)
assertIndexOfFirstX(t, 4, "\x09Johan\x09x", 12)
}
func TestTabHandling_TabSize8(t *testing.T) {
assertIndexOfFirstX(t, 8, "\x09x", 8)
}
func TestCodeHighlighting(t *testing.T) {
// From: https://coderwall.com/p/_fmbug/go-get-path-to-current-file
_, filename, _, ok := runtime.Caller(0)
if !ok {
panic("Getting current filename failed")
}
reader, err := reader.NewFromFilename(filename, formatters.TTY16m, reader.ReaderOptions{Style: styles.Get("native")})
assert.NilError(t, err)
assert.NilError(t, reader.Wait())
packageKeywordStyle := twin.StyleDefault.WithAttr(twin.AttrBold).WithForeground(twin.NewColorHex(0x6AB825))
packageSpaceStyle := twin.StyleDefault.WithForeground(twin.NewColorHex(0x666666))
packageNameStyle := twin.StyleDefault.WithForeground(twin.NewColorHex(0xD0D0D0))
var answers = []twin.StyledRune{
twin.NewStyledRune('p', packageKeywordStyle),
twin.NewStyledRune('a', packageKeywordStyle),
twin.NewStyledRune('c', packageKeywordStyle),
twin.NewStyledRune('k', packageKeywordStyle),
twin.NewStyledRune('a', packageKeywordStyle),
twin.NewStyledRune('g', packageKeywordStyle),
twin.NewStyledRune('e', packageKeywordStyle),
twin.NewStyledRune(' ', packageSpaceStyle),
twin.NewStyledRune('i', packageNameStyle),
twin.NewStyledRune('n', packageNameStyle),
twin.NewStyledRune('t', packageNameStyle),
twin.NewStyledRune('e', packageNameStyle),
twin.NewStyledRune('r', packageNameStyle),
twin.NewStyledRune('n', packageNameStyle),
twin.NewStyledRune('a', packageNameStyle),
twin.NewStyledRune('l', packageNameStyle),
}
contents := startPaging(t, reader).GetRow(0)
for pos, expected := range answers {
assertRunesEqual(t, expected, contents[pos])
}
}
func TestCodeHighlight_compressed(t *testing.T) {
// Same as TestCodeHighlighting but with "compressed-markdown.md.gz"
reader, err := reader.NewFromFilename("../sample-files/compressed-markdown.md.gz", formatters.TTY16m, reader.ReaderOptions{Style: styles.Get("native")})
assert.NilError(t, err)
assert.NilError(t, reader.Wait())
markdownHeading1Style := twin.StyleDefault.WithAttr(twin.AttrBold).WithForeground(twin.NewColorHex(0xffffff))
var answers = []twin.StyledRune{
twin.NewStyledRune('#', markdownHeading1Style),
twin.NewStyledRune(' ', markdownHeading1Style),
twin.NewStyledRune('M', markdownHeading1Style),
twin.NewStyledRune('a', markdownHeading1Style),
twin.NewStyledRune('r', markdownHeading1Style),
twin.NewStyledRune('k', markdownHeading1Style),
twin.NewStyledRune('d', markdownHeading1Style),
twin.NewStyledRune('o', markdownHeading1Style),
twin.NewStyledRune('w', markdownHeading1Style),
twin.NewStyledRune('n', markdownHeading1Style),
}
contents := startPaging(t, reader).GetRow(0)
for pos, expected := range answers {
assertRunesEqual(t, expected, contents[pos])
}
}
// Regression test for:
// https://github.com/walles/moor/issues/236#issuecomment-2282677792
//
// Sample file sysctl.h from:
// https://github.com/fastfetch-cli/fastfetch/blob/f9597eba39d6afd278eeca2f2972f73a7e54f111/src/common/sysctl.h
func TestCodeHighlightingIncludes(t *testing.T) {
reader, err := reader.NewFromFilename("../sample-files/sysctl.h", formatters.TTY16m, reader.ReaderOptions{Style: styles.Get("native")})
assert.NilError(t, err)
assert.NilError(t, reader.Wait())
screen := startPaging(t, reader)
firstIncludeLine := screen.GetRow(2)
secondIncludeLine := screen.GetRow(3)
// Both should start with "#include" colored the same way
assertRunesEqual(t, firstIncludeLine[0], secondIncludeLine[0])
}
func TestUnicodePrivateUse(t *testing.T) {
// This character lives in a Private Use Area:
// https://codepoints.net/U+f244
//
// It's used by Font Awesome as "fa-battery-empty":
// https://fontawesome.com/v4/icon/battery-empty
char := '\uf244'
reader := reader.NewFromTextForTesting("hello", string(char))
renderedRune := startPaging(t, reader).GetRow(0)[0]
// Make sure we display this character unmodified
assertRunesEqual(t, twin.NewStyledRune(char, twin.StyleDefault), renderedRune)
}
func resetManPageFormat() {
textstyles.ManPageBold = twin.StyleDefault.WithAttr(twin.AttrBold)
textstyles.ManPageUnderline = twin.StyleDefault.WithAttr(twin.AttrUnderline)
}
func testManPageFormatting(t *testing.T, input string, expected twin.StyledRune) {
t.Helper()
reader := reader.NewFromTextForTesting("", input)
// Without these lines the man page tests will fail if either of these
// environment variables are set when the tests are run.
assert.NilError(t, os.Setenv("LESS_TERMCAP_md", ""))
assert.NilError(t, os.Setenv("LESS_TERMCAP_us", ""))
assert.NilError(t, os.Setenv("LESS_TERMCAP_so", ""))
resetManPageFormat()
contents := startPaging(t, reader).GetRow(0)
assertRunesEqual(t, expected, contents[0])
assert.Equal(t, contents[1].Rune, ' ')
}
func TestManPageFormatting(t *testing.T) {
testManPageFormatting(t, "n\x08n", twin.NewStyledRune('n', twin.StyleDefault.WithAttr(twin.AttrBold)))
testManPageFormatting(t, "_\x08x", twin.NewStyledRune('x', twin.StyleDefault.WithAttr(twin.AttrUnderline)))
// Non-breaking space UTF-8 encoded (0xc2a0) should render as a non-breaking unicode space (0xa0)
testManPageFormatting(t, string([]byte{0xc2, 0xa0}), twin.NewStyledRune(rune(0xa0), twin.StyleDefault))
// Corner cases
testManPageFormatting(t, "\x08", twin.NewStyledRune('<', twin.StyleDefault.WithForeground(twin.NewColor16(7)).WithBackground(twin.NewColor16(1))))
// FIXME: Test two consecutive backspaces
// FIXME: Test backspace between two uncombinable characters
}
func TestScrollToBottomWrapNextToLastLine(t *testing.T) {
reader := reader.NewFromTextForTesting("",
"first line\nline two will be wrapped\nhere's the last line")
// Heigh 3 = two lines of contents + one footer
screen := twin.NewFakeScreen(10, 3)
pager := NewPager(reader)
pager.WrapLongLines = true
pager.ShowLineNumbers = false
pager.showLineNumbers = false
pager.screen = screen
assert.NilError(t, pager.readers[pager.currentReader].Wait())
// This is what we're testing really
pager.scrollToEnd()
// Exit immediately
pager.Quit()
// Get contents onto our fake screen
pager.StartPaging(screen, nil, nil)
pager.redraw("")
actual := strings.Join([]string{
rowToString(screen.GetRow(0)),
rowToString(screen.GetRow(1)),
rowToString(screen.GetRow(2)),
}, "\n")
expected := strings.Join([]string{
"here's the",
"last line",
"3 lines 1", // "3 lines 100%" clipped after 10 characters (screen width)
}, "\n")
assert.Equal(t, actual, expected)
}
// Repro for https://github.com/walles/moor/issues/105
func TestScrollToEndLongInput(t *testing.T) {
const lineCount = 10100 // At least five digits
// "X" marks the spot
reader := reader.NewFromTextForTesting("test", strings.Repeat(".\n", lineCount-1)+"X")
pager := NewPager(reader)
pager.ShowLineNumbers = true
pager.showLineNumbers = true
// Tell our Pager to quit immediately
pager.Quit()
// Connect the pager with a screen
const screenHeight = 10
screen := twin.NewFakeScreen(20, screenHeight)
pager.StartPaging(screen, nil, nil)
// This is what we're really testing
pager.scrollToEnd()
// This makes sure at least one frame gets rendered
pager.redraw("")
// The last screen line holds the status field, and the next to last screen
// line holds the last contents line.
lastContentsLine := screen.GetRow(screenHeight - 2)
firstContentsColumn := len("10_100 ")
assertRunesEqual(t, twin.NewStyledRune('X', twin.StyleDefault), lastContentsLine[firstContentsColumn])
}
func TestIsScrolledToEnd_LongFile(t *testing.T) {
// Six lines of contents
reader := reader.NewFromTextForTesting("Testing", "a\nb\nc\nd\ne\nf\n")
// Three lines screen
screen := twin.NewFakeScreen(20, 3)
// Create the pager
pager := NewPager(reader)
pager.screen = screen
assert.Equal(t, false, pager.isScrolledToEnd())
pager.scrollToEnd()
assert.Equal(t, true, pager.isScrolledToEnd())
}
func TestIsScrolledToEnd_ShortFile(t *testing.T) {
// Three lines of contents
reader := reader.NewFromTextForTesting("Testing", "a\nb\nc")
// Six lines screen
screen := twin.NewFakeScreen(20, 6)
// Create the pager
pager := NewPager(reader)
pager.screen = screen
assert.Equal(t, true, pager.isScrolledToEnd())
pager.scrollToEnd()
assert.Equal(t, true, pager.isScrolledToEnd())
}
func TestIsScrolledToEnd_ExactFile(t *testing.T) {
// Three lines of contents
reader := reader.NewFromTextForTesting("Testing", "a\nb\nc")
// Three lines screen
screen := twin.NewFakeScreen(20, 3)
// Create the pager
pager := NewPager(reader)
pager.screen = screen
pager.ShowStatusBar = false
assert.Equal(t, true, pager.isScrolledToEnd())
pager.scrollToEnd()
assert.Equal(t, true, pager.isScrolledToEnd())
}
func TestIsScrolledToEnd_WrappedLastLine(t *testing.T) {
// Three lines of contents
reader := reader.NewFromTextForTesting("Testing", "a\nb\nc d e f g h i j k l m n")
// Three lines screen
screen := twin.NewFakeScreen(5, 3)
// Create the pager
pager := NewPager(reader)
pager.screen = screen
pager.WrapLongLines = true
assert.Equal(t, false, pager.isScrolledToEnd())
pager.scrollToEnd()
assert.Equal(t, true, pager.isScrolledToEnd())
pager.mode.onKey(twin.KeyUp)
pager.redraw("XXX")
assert.Equal(t, false, pager.isScrolledToEnd())
}
func TestIsScrolledToEnd_EmptyFile(t *testing.T) {
// No contents
reader := reader.NewFromTextForTesting("Testing", "")
// Three lines screen
screen := twin.NewFakeScreen(20, 3)
// Create the pager
pager := NewPager(reader)
pager.screen = screen
assert.Equal(t, true, pager.isScrolledToEnd())
pager.scrollToEnd()
assert.Equal(t, true, pager.isScrolledToEnd())
}
func getTestFiles(t *testing.T) []string {
files, err := os.ReadDir(samplesDir)
assert.NilError(t, err)
var filenames []string
for _, file := range files {
filenames = append(filenames, path.Join(samplesDir, file.Name()))
}
return filenames
}
// Verify that we can page all files in ../sample-files/* without crashing
func TestPageSamples(t *testing.T) {
for _, fileName := range getTestFiles(t) {
t.Run(fileName, func(t *testing.T) {
file, err := os.Open(fileName)
if err != nil {
t.Errorf("Error opening file <%s>: %s", fileName, err.Error())
return
}
defer func() {
if err := file.Close(); err != nil {
panic(err)
}
}()
myReader, err := reader.NewFromStream(fileName, file, nil, reader.ReaderOptions{Style: &chroma.Style{}})
assert.NilError(t, err)
assert.NilError(t, myReader.Wait())
pager := NewPager(myReader)
pager.WrapLongLines = false
pager.ShowLineNumbers = false
pager.showLineNumbers = false
// Heigh 3 = two lines of contents + one footer
screen := twin.NewFakeScreen(10, 3)
// Exit immediately
pager.Quit()
// Get contents onto our fake screen
pager.StartPaging(screen, nil, nil)
pager.redraw("")
firstReaderLine := myReader.GetLine(linemetadata.Index{})
if firstReaderLine == nil {
return
}
firstPagerLine := rowToString(screen.GetRow(0))
// Handle the case when first line is chopped off to the right
firstPagerLine = strings.TrimSuffix(firstPagerLine, ">")
assert.Assert(t,
strings.HasPrefix(firstReaderLine.Plain(), firstPagerLine),
"\nreader line = <%s>\npager line = <%s>",
firstReaderLine.Plain(), firstPagerLine,
)
})
}
}
// Validate rendering of https://en.wikipedia.org/wiki/ANSI_escape_code#EL
func TestClearToEndOfLine_ClearFromStart(t *testing.T) {
screen := startPaging(t, reader.NewFromTextForTesting("TestClearToEol", blueBackgroundClearToEol))
screenWidth, _ := screen.Size()
var expected []twin.StyledRune
for len(expected) < screenWidth {
expected = append(expected,
twin.NewStyledRune(' ', twin.StyleDefault.WithBackground(twin.NewColor16(4))),
)
}
actual := screen.GetRow(0)
assert.DeepEqual(t, actual, expected, cmp.AllowUnexported(twin.Style{}))
}
// Validate rendering of https://en.wikipedia.org/wiki/ANSI_escape_code#EL
func TestClearToEndOfLine_ClearFromNotStart(t *testing.T) {
screen := startPaging(t, reader.NewFromTextForTesting("TestClearToEol", "a"+blueBackgroundClearToEol))
screenWidth, _ := screen.Size()
expected := []twin.StyledRune{
twin.NewStyledRune('a', twin.StyleDefault),
}
for len(expected) < screenWidth {
expected = append(expected,
twin.NewStyledRune(' ', twin.StyleDefault.WithBackground(twin.NewColor16(4))),
)
}
actual := screen.GetRow(0)
assert.DeepEqual(t, actual, expected, cmp.AllowUnexported(twin.Style{}))
}
// Validate rendering of https://en.wikipedia.org/wiki/ANSI_escape_code#EL
func TestClearToEndOfLine_ClearFromStartScrolledRight(t *testing.T) {
pager := NewPager(reader.NewFromTextForTesting("TestClearToEol", blueBackgroundClearToEol0))
pager.ShowLineNumbers = false
pager.showLineNumbers = false
// Tell our Pager to quit immediately
pager.Quit()
// Except for just quitting, this also associates a FakeScreen with the Pager
screen := twin.NewFakeScreen(3, 10)
pager.StartPaging(screen, nil, nil)
// Scroll right, this is what we're testing
pager.leftColumnZeroBased = 44
// This makes sure at least one frame gets rendered
pager.redraw("")
screenWidth, _ := screen.Size()
var expected []twin.StyledRune
for len(expected) < screenWidth {
expected = append(expected,
twin.NewStyledRune(' ', twin.StyleDefault.WithBackground(twin.NewColor16(4))),
)
}
actual := screen.GetRow(0)
assert.DeepEqual(t, actual, expected, cmp.AllowUnexported(twin.Style{}))
}
// Render a line of text on our 20 cell wide screen
func renderTextLine(text string) string {
reader := reader.NewFromTextForTesting("renderTextLine", text)
screen := startPaging(nil, reader)
return rowToString(screen.GetRow(0))
}
// Ref: https://github.com/walles/moor/issues/243
func TestPageWideChars(t *testing.T) {
// Both of these characters are 2 cells wide on a terminal
const monospaced4cells = "上åˆ"
const monospaced8cells = monospaced4cells + monospaced4cells
const monospaced16cells = monospaced8cells + monospaced8cells
const monospaced20cells = monospaced16cells + monospaced4cells
const monospaced24cells = monospaced16cells + monospaced8cells
// Cut the line in the middle of a wide character
const monospaced18cells = monospaced16cells + "上"
assert.Equal(t, monospaced18cells+" >", renderTextLine(monospaced24cells))
// Just the right length, no cutting
assert.Equal(t, monospaced20cells, renderTextLine(monospaced20cells))
// Cut this line after a whide character
assert.Equal(t, "x"+monospaced18cells+">", renderTextLine("x"+monospaced24cells))
}
func TestTerminalFg(t *testing.T) {
reader := reader.NewFromTextForTesting("", "x")
var styleAnswer = twin.NewStyledRune('x', twin.StyleDefault.WithForeground(twin.NewColor24Bit(0xd0, 0xd0, 0xd0)))
var terminalAnswer = twin.NewStyledRune('x', twin.StyleDefault)
assertRunesEqual(t, styleAnswer, startPagingWithTerminalFg(t, reader, false).GetRow(0)[0])
assertRunesEqual(t, terminalAnswer, startPagingWithTerminalFg(t, reader, true).GetRow(0)[0])
}
func testFooter(t *testing.T, filename string, contents string, expectedFooter string) {
reader := reader.NewFromTextForTesting(filename, contents)
screen := startPagingWithScreen(t, twin.NewFakeScreen(999, 10), reader)
footer := rowToString(screen.GetRow(9))
assert.Equal(t, expectedFooter, footer, fmt.Sprintf("filename='%s', contents='%s'", filename, contents))
}
func TestFooter(t *testing.T) {
help := "Press ESC / q to exit, / to search, & to filter, h for help"
testFooter(t, "filename", "", "filename: "+help)
testFooter(t, "", "", " "+help)
testFooter(t, "", "text", "1 line 100% "+help)
testFooter(t, "filename", "text", "filename: 1 line 100% "+help)
testFooter(t, "", "line 1\nline 2", "2 lines 100% "+help)
}
// Regression test for crash when following an empty file.
// Before the fix, IndexFromLength(0) would return nil, and calling .IsBefore()
// on nil would crash.
func TestHandleMoreLinesAvailableWithEmptyFile(t *testing.T) {
// Create a pager with an empty reader
emptyReader := reader.NewFromTextForTesting("empty", "")
pager := NewPager(emptyReader)
// Simulate --follow mode by setting target to max
targetLine := linemetadata.IndexMax()
pager.TargetLine = &targetLine
// This should not crash when lineCount is 0
pager.handleMoreLinesAvailable()
// Verify target line is still set (we're still waiting for lines)
if pager.TargetLine == nil {
t.Error("Expected TargetLine to remain set when no lines available")
}
}
moor-2.10.3/internal/pagermode-colon-command.go 0000664 0000000 0000000 00000002532 15135744745 0021424 0 ustar 00root root 0000000 0000000 package internal
import (
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/twin"
)
type PagerModeColonCommand struct {
pager *Pager
}
func (m *PagerModeColonCommand) drawFooter(_ string, _ string, _ string) {
p := m.pager
_, height := p.screen.Size()
pos := 0
for _, token := range "Go to [n]ext, [p]revious or first [x] file: " {
pos += p.screen.SetCell(pos, height-1, twin.NewStyledRune(token, twin.StyleDefault))
}
// Add a cursor
p.screen.SetCell(pos, height-1, twin.NewStyledRune(' ', twin.StyleDefault.WithAttr(twin.AttrReverse)))
}
func (m *PagerModeColonCommand) onKey(key twin.KeyCode) {
p := m.pager
switch key {
case twin.KeyEscape:
p.mode = PagerModeViewing{pager: p}
default:
log.Tracef("Unhandled colon command event %v, treating as a viewing key event", key)
p.mode = PagerModeViewing{pager: p}
p.mode.onKey(key)
}
}
func (m *PagerModeColonCommand) onRune(char rune) {
p := m.pager
if char == 'q' {
// Back to viewing mode, just like ESC
p.mode = PagerModeViewing{pager: p}
}
if char == 'p' {
p.mode = PagerModeViewing{pager: p}
p.previousFile()
return
}
if char == 'n' {
p.mode = PagerModeViewing{pager: p}
p.nextFile()
return
}
if char == 'x' {
p.mode = PagerModeViewing{pager: p}
p.firstFile()
return
}
log.Debugf("Unhandled colon command rune %q, ignoring it", char)
}
moor-2.10.3/internal/pagermode-filter.go 0000664 0000000 0000000 00000002452 15135744745 0020164 0 ustar 00root root 0000000 0000000 package internal
import (
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/internal/search"
"github.com/walles/moor/v2/twin"
)
type PagerModeFilter struct {
pager *Pager
inputBox *InputBox
}
func NewPagerModeFilter(p *Pager) *PagerModeFilter {
m := &PagerModeFilter{
pager: p,
}
m.inputBox = &InputBox{
accept: INPUTBOX_ACCEPT_ALL,
onTextChanged: func(text string) {
m.updateFilterPattern(text)
},
}
return m
}
func (m PagerModeFilter) drawFooter(_ string, _ string, _ string) {
m.inputBox.draw(m.pager.screen, "Type to filter, 'ENTER' submits, 'ESC' cancels", "Filter: ")
}
func (m *PagerModeFilter) updateFilterPattern(text string) {
m.pager.filter.For(text)
m.pager.search.For(text)
}
func (m *PagerModeFilter) onKey(key twin.KeyCode) {
if m.inputBox.handleKey(key) {
return
}
switch key {
case twin.KeyEnter:
m.pager.mode = PagerModeViewing{pager: m.pager}
case twin.KeyEscape:
m.pager.mode = PagerModeViewing{pager: m.pager}
m.pager.filter = search.Search{}
m.pager.search.Clear()
case twin.KeyUp, twin.KeyDown, twin.KeyPgUp, twin.KeyPgDown:
viewing := PagerModeViewing{pager: m.pager}
viewing.onKey(key)
default:
log.Debugf("Unhandled filter key event %v", key)
}
}
func (m *PagerModeFilter) onRune(char rune) {
m.inputBox.handleRune(char)
}
moor-2.10.3/internal/pagermode-go-to-line.go 0000664 0000000 0000000 00000003527 15135744745 0020655 0 ustar 00root root 0000000 0000000 package internal
import (
"strconv"
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/twin"
)
type PagerModeGotoLine struct {
pager *Pager
inputBox InputBox
}
func NewPagerModeGotoLine(p *Pager) *PagerModeGotoLine {
m := &PagerModeGotoLine{
pager: p,
inputBox: InputBox{
accept: INPUTBOX_ACCEPT_POSITIVE_NUMBERS,
onTextChanged: nil,
},
}
return m
}
func (m *PagerModeGotoLine) drawFooter(_ string, _ string, _ string) {
m.inputBox.draw(m.pager.screen, "'ENTER' submits, 'ESC' cancels", "Go to line number: ")
}
func (m *PagerModeGotoLine) updateLineNumber(text string) {
newLineNumber, err := strconv.Atoi(text)
if err != nil {
log.Debugf("Got non-number goto text '%s'", text)
return
}
if newLineNumber < 1 {
log.Debugf("Got non-positive goto line number: %d", newLineNumber)
return
}
targetIndex := linemetadata.IndexFromOneBased(newLineNumber)
m.pager.scrollPosition = NewScrollPositionFromIndex(
targetIndex,
"onGotoLineKey",
)
m.pager.setTargetLine(&targetIndex)
}
func (m *PagerModeGotoLine) onKey(key twin.KeyCode) {
if m.inputBox.handleKey(key) {
return
}
switch key {
case twin.KeyEnter:
m.updateLineNumber(m.inputBox.text)
m.pager.mode = PagerModeViewing{pager: m.pager}
case twin.KeyEscape:
m.pager.mode = PagerModeViewing{pager: m.pager}
default:
log.Tracef("Unhandled goto key event %v, treating as a viewing key event", key)
m.pager.mode = PagerModeViewing{pager: m.pager}
m.pager.mode.onKey(key)
}
}
func (m *PagerModeGotoLine) onRune(char rune) {
p := m.pager
if char == 'q' {
p.mode = PagerModeViewing{pager: p}
return
}
if char == 'g' {
p.scrollPosition = newScrollPosition("Pager scroll position")
p.handleScrolledUp()
p.mode = PagerModeViewing{pager: p}
return
}
m.inputBox.handleRune(char)
}
moor-2.10.3/internal/pagermode-info.go 0000664 0000000 0000000 00000001250 15135744745 0017625 0 ustar 00root root 0000000 0000000 // Show some info to the user. Fall back to viewing on any input.
package internal
import (
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/twin"
)
type PagerModeInfo struct {
Pager *Pager
Text string
logged bool
}
func (m *PagerModeInfo) drawFooter(_ string, _ string, _ string) {
if !m.logged {
log.Infof("Displaying info message to user: %q", m.Text)
m.logged = true
}
m.Pager.setFooter(m.Text, "", "", "")
}
func (m *PagerModeInfo) onKey(key twin.KeyCode) {
m.Pager.mode = PagerModeViewing{m.Pager}
m.Pager.mode.onKey(key)
}
func (m *PagerModeInfo) onRune(char rune) {
m.Pager.mode = PagerModeViewing{m.Pager}
m.Pager.mode.onRune(char)
}
moor-2.10.3/internal/pagermode-jump-to-mark.go 0000664 0000000 0000000 00000003053 15135744745 0021220 0 ustar 00root root 0000000 0000000 package internal
import (
"sort"
"github.com/walles/moor/v2/twin"
"golang.org/x/exp/maps"
)
type PagerModeJumpToMark struct {
pager *Pager
}
func (m PagerModeJumpToMark) drawFooter(_ string, _ string, _ string) {
p := m.pager
_, height := p.screen.Size()
pos := 0
for _, token := range m.getMarkPrompt() {
pos += p.screen.SetCell(pos, height-1, twin.NewStyledRune(token, twin.StyleDefault))
}
}
func (m PagerModeJumpToMark) getMarkPrompt() string {
// Special case having zero, one or multiple marks
if len(m.pager.bookmarks) == 0 {
return "No marks set, press 'm' to set one!"
}
if len(m.pager.bookmarks) == 1 {
for key := range m.pager.bookmarks {
return "Jump to your mark: " + string(key)
}
}
// Multiple marks, list them
marks := maps.Keys(m.pager.bookmarks)
sort.Slice(marks, func(i, j int) bool {
return marks[i] < marks[j]
})
prompt := "Jump to one of these marks: "
for i, mark := range marks {
if i > 0 {
prompt += ", "
}
prompt += string(mark)
}
return prompt
}
func (m PagerModeJumpToMark) onKey(key twin.KeyCode) {
p := m.pager
switch key {
case twin.KeyEnter, twin.KeyEscape:
// Never mind I
p.mode = PagerModeViewing{pager: p}
default:
// Never mind II
p.mode = PagerModeViewing{pager: p}
p.mode.onKey(key)
}
}
func (m PagerModeJumpToMark) onRune(char rune) {
if len(m.pager.bookmarks) == 0 && char == 'm' {
m.pager.mode = PagerModeMark(m)
return
}
destination, ok := m.pager.bookmarks[char]
if ok {
m.pager.scrollPosition = destination
}
m.pager.mode = PagerModeViewing(m)
}
moor-2.10.3/internal/pagermode-mark.go 0000664 0000000 0000000 00000001603 15135744745 0017626 0 ustar 00root root 0000000 0000000 package internal
import "github.com/walles/moor/v2/twin"
type PagerModeMark struct {
pager *Pager
}
func (m PagerModeMark) drawFooter(_ string, _ string, _ string) {
p := m.pager
_, height := p.screen.Size()
pos := 0
for _, token := range "Press any key to label your mark: " {
pos += p.screen.SetCell(pos, height-1, twin.NewStyledRune(token, twin.StyleDefault))
}
// Add a cursor
p.screen.SetCell(pos, height-1, twin.NewStyledRune(' ', twin.StyleDefault.WithAttr(twin.AttrReverse)))
}
func (m PagerModeMark) onKey(key twin.KeyCode) {
p := m.pager
switch key {
case twin.KeyEnter, twin.KeyEscape:
// Never mind I
p.mode = PagerModeViewing{pager: p}
default:
// Never mind II
p.mode = PagerModeViewing{pager: p}
p.mode.onKey(key)
}
}
func (m PagerModeMark) onRune(char rune) {
m.pager.bookmarks[char] = m.pager.scrollPosition
m.pager.mode = PagerModeViewing(m)
}
moor-2.10.3/internal/pagermode-not-found.go 0000664 0000000 0000000 00000001356 15135744745 0020612 0 ustar 00root root 0000000 0000000 package internal
import "github.com/walles/moor/v2/twin"
type PagerModeNotFound struct {
pager *Pager
}
func (m PagerModeNotFound) drawFooter(_ string, _ string, _ string) {
m.pager.setFooter("Not found: "+m.pager.search.String(), "", "", "")
}
func (m PagerModeNotFound) onKey(key twin.KeyCode) {
m.pager.mode = PagerModeViewing(m)
m.pager.mode.onKey(key)
}
func (m PagerModeNotFound) onRune(char rune) {
switch char {
// Should match the pagermode-viewing.go next-search-hit bindings
case 'n':
m.pager.scrollToNextSearchHit()
// Should match the pagermode-viewing.go previous-search-hit bindings
case 'p', 'N':
m.pager.scrollToPreviousSearchHit()
default:
m.pager.mode = PagerModeViewing(m)
m.pager.mode.onRune(char)
}
}
moor-2.10.3/internal/pagermode-not-found_test.go 0000664 0000000 0000000 00000002733 15135744745 0021651 0 ustar 00root root 0000000 0000000 package internal
import (
"testing"
"github.com/walles/moor/v2/internal/reader"
"github.com/walles/moor/v2/twin"
"gotest.tools/v3/assert"
)
// Repro for not-found part of https://github.com/walles/moor/issues/182
func TestNotFoundFindPrevious(t *testing.T) {
reader := reader.NewFromTextForTesting("TestNotFoundFindPrevious", "apa\nbepa\ncepa\ndepa")
pager := NewPager(reader)
pager.screen = twin.NewFakeScreen(40, 2)
assert.NilError(t, reader.Wait())
// Look for a hit on the second line
pager.search.For("bepa")
// Press 'p' to find the previous hit
pager.mode = PagerModeNotFound{pager: pager}
pager.mode.onRune('p')
// We should now be on the second line saying "bepa"
assert.Equal(t, pager.scrollPosition.lineIndex(pager).Index(), 1)
assert.Assert(t, pager.isViewing())
}
func TestWrapSearchBackwards(t *testing.T) {
reader := reader.NewFromTextForTesting("TestNotFoundFindPrevious", "gold\napa\nbepa\ngold")
pager := NewPager(reader)
pager.screen = twin.NewFakeScreen(40, 3)
assert.NilError(t, reader.Wait())
// Looking for this should take us to the last line
pager.search.For("gold")
// Press 'p' to find the previous hit
pager.mode = PagerModeNotFound{pager: pager}
pager.mode.onRune('p')
// We should now have found gold on the last line. Since the pager is
// showing two lines on the screen, this puts the pager line number at 3
// (not 4).
assert.Equal(t, pager.scrollPosition.lineIndex(pager).Index(), 2)
assert.Assert(t, pager.isViewing())
}
moor-2.10.3/internal/pagermode-search.go 0000664 0000000 0000000 00000006422 15135744745 0020145 0 ustar 00root root 0000000 0000000 package internal
import (
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/twin"
)
type SearchDirection bool
const (
SearchDirectionForward SearchDirection = false
SearchDirectionBackward SearchDirection = true
)
type PagerModeSearch struct {
pager *Pager
initialScrollPosition scrollPosition // Pager position before search started
direction SearchDirection
inputBox *InputBox
searchHistoryIndex int
userEditedText string
}
func NewPagerModeSearch(p *Pager, direction SearchDirection, initialScrollPosition scrollPosition) *PagerModeSearch {
m := &PagerModeSearch{
pager: p,
initialScrollPosition: initialScrollPosition,
direction: direction,
searchHistoryIndex: len(p.searchHistory.entries), // Past the end
}
m.inputBox = &InputBox{
accept: INPUTBOX_ACCEPT_ALL,
onTextChanged: func(text string) {
m.pager.search.For(text)
switch m.direction {
case SearchDirectionBackward:
m.pager.scrollToSearchHitsBackwards()
case SearchDirectionForward:
m.pager.scrollToSearchHits()
}
},
}
return m
}
func (m PagerModeSearch) drawFooter(_ string, _ string, _ string) {
prompt := "Search: "
if m.direction == SearchDirectionBackward {
prompt = "Search backwards: "
}
m.inputBox.draw(m.pager.screen, "Type to search, 'ENTER' submits, 'ESC' cancels, '↑↓' navigate history", prompt)
}
func (m *PagerModeSearch) moveSearchHistoryIndex(delta int) {
if len(m.pager.searchHistory.entries) == 0 {
return
}
m.searchHistoryIndex += delta
if m.searchHistoryIndex < 0 {
m.searchHistoryIndex = 0
}
if m.searchHistoryIndex > len(m.pager.searchHistory.entries) {
m.searchHistoryIndex = len(m.pager.searchHistory.entries) // Beyond the end of the history
}
if m.searchHistoryIndex == len(m.pager.searchHistory.entries) {
// Reset to whatever the user typed last
m.inputBox.setText(m.userEditedText)
} else {
// Get the history entry
m.inputBox.setText(m.pager.searchHistory.entries[m.searchHistoryIndex])
}
}
func (m *PagerModeSearch) onKey(key twin.KeyCode) {
if m.inputBox.handleKey(key) {
m.searchHistoryIndex = len(m.pager.searchHistory.entries) // Reset history index when user types
m.userEditedText = m.inputBox.text
return
}
switch key {
case twin.KeyEnter:
m.pager.searchHistory.addEntry(m.inputBox.text)
m.pager.mode = PagerModeViewing{pager: m.pager}
m.pager.setTargetLine(nil) // Viewing doesn't need all lines
case twin.KeyEscape:
m.pager.searchHistory.addEntry(m.inputBox.text)
m.pager.mode = PagerModeViewing{pager: m.pager}
m.pager.scrollPosition = m.initialScrollPosition
m.pager.setTargetLine(nil) // Viewing doesn't need all lines
case twin.KeyPgUp, twin.KeyPgDown:
m.pager.searchHistory.addEntry(m.inputBox.text)
m.pager.mode = PagerModeViewing{pager: m.pager}
m.pager.mode.onKey(key)
m.pager.setTargetLine(nil) // Viewing doesn't need all lines
case twin.KeyUp:
m.moveSearchHistoryIndex(-1)
case twin.KeyDown:
m.moveSearchHistoryIndex(1)
default:
log.Debugf("Unhandled search key event %v", key)
}
}
func (m *PagerModeSearch) onRune(char rune) {
m.searchHistoryIndex = len(m.pager.searchHistory.entries) // Reset history index when user types
m.inputBox.handleRune(char)
m.userEditedText = m.inputBox.text
}
moor-2.10.3/internal/pagermode-viewing.go 0000664 0000000 0000000 00000014315 15135744745 0020350 0 ustar 00root root 0000000 0000000 package internal
import (
"fmt"
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/search"
"github.com/walles/moor/v2/internal/textstyles"
"github.com/walles/moor/v2/twin"
)
type PagerModeViewing struct {
pager *Pager
}
func (m PagerModeViewing) drawFooter(filenameText string, statusText string, spinner string) {
prefix := ""
colonHelp := ""
m.pager.readerLock.Lock()
if len(m.pager.readers) > 1 {
prefix = fmt.Sprintf("[%d/%d] ", m.pager.currentReader+1, len(m.pager.readers))
colonHelp = "':' to switch, "
}
m.pager.readerLock.Unlock()
searchHelp := "'/' to search"
if !m.pager.search.Inactive() {
searchHelp = "'n'/'p' to search next/previous"
}
helpText := "Press 'ESC' / 'q' to exit, " + colonHelp + searchHelp + ", '&' to filter, 'h' for help"
if m.pager.isShowingHelp {
helpText = "Press 'ESC' / 'q' to exit help, " + searchHelp
prefix = ""
}
if m.pager.ShowStatusBar {
if len(spinner) > 0 {
spinner = " " + spinner
}
m.pager.setFooter(prefix, filenameText, statusText+spinner, helpText)
}
}
func (m PagerModeViewing) onKey(keyCode twin.KeyCode) {
p := m.pager
switch keyCode {
case twin.KeyEscape:
p.Quit()
case twin.KeyUp:
// Clipping is done in _Redraw()
p.scrollPosition = p.scrollPosition.PreviousLine(1)
p.handleScrolledUp()
case twin.KeyDown, twin.KeyEnter:
// Clipping is done in _Redraw()
p.scrollPosition = p.scrollPosition.NextLine(1)
p.handleScrolledDown()
case twin.KeyRight:
p.moveRight(p.SideScrollAmount)
case twin.KeyLeft:
p.moveRight(-p.SideScrollAmount)
case twin.KeyAltRight:
p.moveRight(1)
case twin.KeyAltLeft:
p.moveRight(-1)
case twin.KeyHome:
p.scrollPosition = newScrollPosition("Pager scroll position")
p.handleScrolledUp()
case twin.KeyEnd:
p.scrollToEnd()
case twin.KeyPgUp:
p.scrollPosition = p.scrollPosition.PreviousLine(p.visibleHeight())
p.handleScrolledUp()
case twin.KeyPgDown:
p.scrollPosition = p.scrollPosition.NextLine(p.visibleHeight())
p.handleScrolledDown()
default:
log.Debugf("Unhandled key event %v", keyCode)
}
}
func (m PagerModeViewing) onRune(char rune) {
p := m.pager
switch char {
case 'q':
p.Quit()
case 'v':
handleEditingRequest(p)
case 'h':
if p.isShowingHelp {
break
}
p.preHelpState = &_PreHelpState{
scrollPosition: p.scrollPosition,
leftColumnZeroBased: p.leftColumnZeroBased,
targetLine: p.TargetLine,
}
p.scrollPosition = newScrollPosition("Pager scroll position")
p.leftColumnZeroBased = 0
p.setTargetLine(nil)
p.isShowingHelp = true
case '=':
p.ShowStatusBar = !p.ShowStatusBar
// '\x10' = CTRL-p, should scroll up one line.
// Ref: https://github.com/walles/moor/issues/107#issuecomment-1328354080
case 'k', 'y', '\x10':
// Clipping is done in _Redraw()
p.scrollPosition = p.scrollPosition.PreviousLine(1)
p.handleScrolledUp()
// '\x0e' = CTRL-n, should scroll down one line.
// Ref: https://github.com/walles/moor/issues/107#issuecomment-1328354080
case 'j', 'e', '\x0e':
// Clipping is done in _Redraw()
p.scrollPosition = p.scrollPosition.NextLine(1)
p.handleScrolledDown()
case '<':
p.scrollPosition = newScrollPosition("Pager scroll position")
p.handleScrolledUp()
case '>', 'G':
p.scrollToEnd()
case 'f', ' ':
p.scrollPosition = p.scrollPosition.NextLine(p.visibleHeight())
p.handleScrolledDown()
case 'b':
p.scrollPosition = p.scrollPosition.PreviousLine(p.visibleHeight())
p.handleScrolledUp()
// '\x15' = CTRL-u, should work like just 'u'.
// Ref: https://github.com/walles/moor/issues/90
case 'u', '\x15':
p.scrollPosition = p.scrollPosition.PreviousLine(p.visibleHeight() / 2)
p.handleScrolledUp()
// '\x04' = CTRL-d, should work like just 'd'.
// Ref: https://github.com/walles/moor/issues/90
case 'd', '\x04':
p.scrollPosition = p.scrollPosition.NextLine(p.visibleHeight() / 2)
p.handleScrolledDown()
case '/':
p.mode = NewPagerModeSearch(p, SearchDirectionForward, p.scrollPosition)
p.search.Clear()
// Searchers want to scan the whole file, start reading as much as we can
reallyHigh := linemetadata.IndexMax()
p.setTargetLine(&reallyHigh)
case '?':
p.mode = NewPagerModeSearch(p, SearchDirectionBackward, p.scrollPosition)
p.search.Clear()
// Searchers want to scan the whole file, start reading as much as we can
reallyHigh := linemetadata.IndexMax()
p.setTargetLine(&reallyHigh)
case '&':
if !p.isShowingHelp {
// Filtering the help text is not supported. Feel free to work on
// that if you feel that's time well spent.
p.mode = NewPagerModeFilter(p)
p.search.Clear()
p.filter = search.Search{}
}
case 'g':
p.mode = NewPagerModeGotoLine(p)
p.setTargetLine(nil)
case ':':
if len(p.readers) > 1 {
p.mode = &PagerModeColonCommand{pager: p}
p.setTargetLine(nil)
} else {
p.mode = &PagerModeInfo{Pager: p, Text: "Pass more files on the command line to be able to switch between them."}
}
// Should match the pagermode-not-found.go previous-search-hit bindings
case 'n':
p.scrollToNextSearchHit()
// Should match the pagermode-not-found.go next-search-hit bindings
case 'p', 'N':
p.scrollToPreviousSearchHit()
case 'm':
p.mode = PagerModeMark{pager: p}
p.setTargetLine(nil)
case '\'':
p.mode = PagerModeJumpToMark{pager: p}
p.setTargetLine(nil)
case 'w':
p.WrapLongLines = !p.WrapLongLines
if p.WrapLongLines {
p.mode = &PagerModeInfo{Pager: p, Text: "Word wrapping enabled"}
} else {
p.mode = &PagerModeInfo{Pager: p, Text: "Word wrapping disabled"}
}
case '\x14': // CTRL-t
p.cycleTabSize()
case '\x01': // CTRL-a
p.leftColumnZeroBased = 0
if !p.showLineNumbers {
// Line numbers not visible, turn them on if the user wants them.
p.showLineNumbers = p.ShowLineNumbers
}
default:
log.Debugf("Unhandled rune keypress '%s'/0x%08x", string(char), int32(char))
}
}
func (p *Pager) cycleTabSize() {
switch p.TabSize {
case 8:
p.TabSize = 4
default:
// We really want to toggle betwewen 4 and 8, but if we start out
// somewhere else let's just go for 8. That's less' default tab size.
p.TabSize = 8
}
textstyles.TabSize = p.TabSize
p.mode = &PagerModeInfo{Pager: p, Text: fmt.Sprintf("Tab size set to %d", p.TabSize)}
}
moor-2.10.3/internal/pagermode-viewing_test.go 0000664 0000000 0000000 00000002632 15135744745 0021406 0 ustar 00root root 0000000 0000000 package internal
import (
"os"
"testing"
"github.com/walles/moor/v2/internal/reader"
"github.com/walles/moor/v2/twin"
"gotest.tools/v3/assert"
)
func TestErrUnlessExecutable_yes(t *testing.T) {
// Find our own executable
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
// Check that it's executable
err = errUnlessExecutable(executable)
if err != nil {
t.Fatal(err)
}
}
func TestErrUnlessExecutable_no(t *testing.T) {
textFile := "pagermode-viewing_test.go"
if _, err := os.Stat(textFile); os.IsNotExist(err) {
t.Fatal("Test setup failed, text file not found: " + textFile)
}
err := errUnlessExecutable(textFile)
if err == nil {
t.Fatal("Expected error, got nil")
}
}
func TestViewingFooter_WithSpinner(t *testing.T) {
r := reader.NewFromTextForTesting("", "text")
pager := NewPager(r)
pager.ShowStatusBar = true
// Attach a fake screen large enough to render the footer
screen := twin.NewFakeScreen(80, 10)
pager.screen = screen
// Drive the footer rendering directly via PagerModeViewing
mode := PagerModeViewing{pager: pager}
spinner := "<->"
mode.drawFooter("", "1 line 100%", spinner)
footer := rowToString(screen.GetRow(9))
// Quotes are stripped in rendering; expect plain keys
expectedHelp := "Press ESC / q to exit, / to search, & to filter, h for help"
expected := "1 line 100% " + spinner + " " + expectedHelp
assert.Equal(t, expected, footer)
}
moor-2.10.3/internal/panicHandler.go 0000664 0000000 0000000 00000000613 15135744745 0017323 0 ustar 00root root 0000000 0000000 package internal
// NOTE: This file should be identical to twin/panicHandler.go
import (
log "github.com/sirupsen/logrus"
)
func PanicHandler(goroutineName string, recoverResult any, stackTrace []byte) {
if recoverResult == nil {
return
}
log.WithFields(log.Fields{
"panic": recoverResult,
"stackTrace": string(stackTrace),
}).Error("Goroutine panicked: " + goroutineName)
}
moor-2.10.3/internal/reader/ 0000775 0000000 0000000 00000000000 15135744745 0015646 5 ustar 00root root 0000000 0000000 moor-2.10.3/internal/reader/highlight.go 0000664 0000000 0000000 00000003171 15135744745 0020146 0 ustar 00root root 0000000 0000000 package reader
import (
"bytes"
"strings"
"github.com/alecthomas/chroma/v2"
)
// Read and highlight some text using Chroma:
// https://github.com/alecthomas/chroma
//
// If lexer is nil no highlighting will be performed.
//
// Returns nil with no error if highlighting would be a no-op.
func Highlight(text string, style chroma.Style, formatter chroma.Formatter, lexer chroma.Lexer) (*string, error) {
if lexer == nil {
// No highlighter available for this file type
return nil, nil
}
// FIXME: Can we test for the lexer implementation class instead? That
// should be more resilient towards this arbitrary string changing if we
// upgrade Chroma at some point.
if lexer.Config().Name == "plaintext" {
// This highlighter doesn't provide any highlighting, but not doing
// anything at all is cheaper and simpler, so we do that.
return nil, nil
}
// NOTE: We used to do...
//
// lexer = chroma.Coalesce(lexer)
//
// ... here, but with Chroma 2.12.0 that resulted in this problem:
// https://github.com/walles/moor/issues/236#issuecomment-2282677792
//
// So let's not do that anymore.
iterator, err := lexer.Tokenise(nil, text)
if err != nil {
return nil, err
}
var stringBuffer bytes.Buffer
err = formatter.Format(&stringBuffer, &style, iterator)
if err != nil {
return nil, err
}
highlighted := stringBuffer.String()
// If buffer ends with SGR Reset ("[0m"), remove it. Chroma sometimes
// (always?) puts one of those by itself on the last line, making us believe
// there is one line too many.
sgrReset := "\x1b[0m"
trimmed := strings.TrimSuffix(highlighted, sgrReset)
return &trimmed, nil
}
moor-2.10.3/internal/reader/inspection-reader.go 0000664 0000000 0000000 00000000664 15135744745 0021616 0 ustar 00root root 0000000 0000000 package reader
import "io"
// Pass-through reader that counts the number of bytes read.
type inspectionReader struct {
base io.Reader
bytesCount int64
endedWithNewline bool
}
func (r *inspectionReader) Read(p []byte) (n int, err error) {
n, err = r.base.Read(p)
r.bytesCount += int64(n)
if err != nil {
return
}
if n > 0 {
r.endedWithNewline = p[n-1] == '\n'
} else {
r.endedWithNewline = false
}
return
}
moor-2.10.3/internal/reader/line.go 0000664 0000000 0000000 00000003255 15135744745 0017131 0 ustar 00root root 0000000 0000000 package reader
import (
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/search"
"github.com/walles/moor/v2/internal/textstyles"
"github.com/walles/moor/v2/twin"
)
// Returns a representation of the string split into styled tokens. Any regexp
// matches are highlighted. A nil regexp means no highlighting.
//
// maxTokensCount: at most this many tokens will be included in the result. If
// 0, do all runes. For BenchmarkRenderHugeLine() performance.
func (line *Line) HighlightedTokens(
plainTextStyle twin.Style,
searchHitStyle twin.Style,
search search.Search,
lineIndex linemetadata.Index,
maxTokensCount int,
) textstyles.StyledRunesWithTrailer {
matchRanges := search.GetMatchRanges(line.Plain(lineIndex))
fromString := textstyles.StyledRunesFromString(plainTextStyle, string(line.raw), &lineIndex, maxTokensCount)
returnRunes := make([]textstyles.CellWithMetadata, 0, len(fromString.StyledRunes))
lastWasSearchHit := false
for _, token := range fromString.StyledRunes {
style := token.Style
searchHit := matchRanges.InRange(len(returnRunes))
if searchHit {
// Highlight the search hit
style = searchHitStyle
}
returnRunes = append(returnRunes, textstyles.CellWithMetadata{
Rune: token.Rune,
Style: style,
IsSearchHit: searchHit,
StartsSearchHit: searchHit && !lastWasSearchHit,
})
lastWasSearchHit = searchHit
}
return textstyles.StyledRunesWithTrailer{
StyledRunes: returnRunes,
Trailer: fromString.Trailer,
ContainsSearchHit: !matchRanges.Empty(),
}
}
func (line *Line) HasManPageFormatting() bool {
return textstyles.HasManPageFormatting(string(line.raw))
}
moor-2.10.3/internal/reader/linePool.go 0000664 0000000 0000000 00000000677 15135744745 0017770 0 ustar 00root root 0000000 0000000 package reader
// This value affects BenchmarkReadLargeFile() performance. Validate changes
// like this:
//
// go test -benchmem -run='^$' -bench 'BenchmarkReadLargeFile' ./internal/reader
const linePoolSize = 1000
type linePool struct {
pool []Line
}
func (lp *linePool) create(raw []byte) *Line {
if len(lp.pool) == 0 {
lp.pool = make([]Line, linePoolSize)
}
line := &lp.pool[0]
lp.pool = lp.pool[1:]
line.raw = raw
return line
}
moor-2.10.3/internal/reader/line_test.go 0000664 0000000 0000000 00000005335 15135744745 0020171 0 ustar 00root root 0000000 0000000 package reader
import (
"testing"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/search"
"github.com/walles/moor/v2/internal/textstyles"
"github.com/walles/moor/v2/twin"
"gotest.tools/v3/assert"
)
func TestHighlightedTokensWithManPageHeading(t *testing.T) {
// Set a marker style we can recognize and test for
textstyles.ManPageHeading = twin.StyleDefault.WithForeground(twin.NewColor16(2))
headingText := "JOHAN"
manPageHeading := ""
for _, char := range headingText {
manPageHeading += string(char) + "\b" + string(char)
}
highlighted := textstyles.StyledRunesFromString(twin.StyleDefault, manPageHeading, nil, 0).StyledRunes
assert.Equal(t, len(highlighted), len(headingText))
for i, cell := range highlighted {
assert.Equal(t, cell.Rune, rune(headingText[i]))
assert.Equal(t, cell.Style, textstyles.ManPageHeading)
}
}
// Verify that a multi-rune search hit spanning a simulated wrap boundary
// propagates the search-hit markers to both sub-lines.
//
// We don't call the actual wrapLine() here (different package + unexported);
// instead we simulate a wrap by slicing at a chosen width. All runes are
// single-width here so rune index == screen column.
func TestSearchHitSpanningWrapBoundary(t *testing.T) {
// Arrange: a line where the search hit crosses index 5
line := NewFromTextForTesting("TestSearchHitSpanningWrapBoundary", "0123456789").GetLine(linemetadata.Index{}).Line
searchHitStyle := twin.StyleDefault.WithForeground(twin.NewColor16(3))
// Match runs from indices 3..8 inclusive ("345678")
highlighted := line.HighlightedTokens(twin.StyleDefault, searchHitStyle, search.For("345678"), linemetadata.Index{}, 0)
// Sanity: overall line reports having a search hit
assert.Assert(t, highlighted.ContainsSearchHit, "Expected overall line to contain search hit")
wrapWidth := 5 // Split after index 4
if len(highlighted.StyledRunes) <= wrapWidth+1 {
t.Fatalf("Unexpected rune count %d, need > %d", len(highlighted.StyledRunes), wrapWidth+1)
}
first := textstyles.CellWithMetadataSlice(highlighted.StyledRunes[:wrapWidth])
second := textstyles.CellWithMetadataSlice(highlighted.StyledRunes[wrapWidth:])
// Assert: both wrapped parts contain search hit cells (continuation preserved)
assert.Assert(t, first.ContainsSearchHit(), "First part should contain start of search hit")
assert.Assert(t, second.ContainsSearchHit(), "Second part should contain continuation of search hit spanning wrap")
// Additionally ensure styling applied to all hit cells (foreground color matches)
for _, cell := range append(first, second...) {
if cell.IsSearchHit && !cell.Style.Equal(searchHitStyle) {
t.Fatalf("Search hit cell %#v does not have expected searchHitStyle", cell)
}
}
}
moor-2.10.3/internal/reader/numberedLine.go 0000664 0000000 0000000 00000001720 15135744745 0020606 0 ustar 00root root 0000000 0000000 package reader
import (
"github.com/rivo/uniseg"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/search"
"github.com/walles/moor/v2/internal/textstyles"
"github.com/walles/moor/v2/twin"
)
type NumberedLine struct {
Index linemetadata.Index
Number linemetadata.Number
Line *Line
}
func (nl *NumberedLine) Plain() string {
return nl.Line.Plain(nl.Index)
}
// maxTokensCount: at most this many tokens will be included in the result. If
// 0, do all runes. For BenchmarkRenderHugeLine() performance.
func (nl *NumberedLine) HighlightedTokens(plainTextStyle twin.Style, searchHitStyle twin.Style, search search.Search, maxTokensCount int) textstyles.StyledRunesWithTrailer {
return nl.Line.HighlightedTokens(plainTextStyle, searchHitStyle, search, nl.Index, maxTokensCount)
}
func (nl *NumberedLine) DisplayWidth() int {
width := 0
for _, r := range nl.Plain() {
width += uniseg.StringWidth(string(r))
}
return width
}
moor-2.10.3/internal/reader/panicHandler.go 0000664 0000000 0000000 00000000611 15135744745 0020563 0 ustar 00root root 0000000 0000000 package reader
// NOTE: This file should be identical to twin/panicHandler.go
import (
log "github.com/sirupsen/logrus"
)
func PanicHandler(goroutineName string, recoverResult any, stackTrace []byte) {
if recoverResult == nil {
return
}
log.WithFields(log.Fields{
"panic": recoverResult,
"stackTrace": string(stackTrace),
}).Error("Goroutine panicked: " + goroutineName)
}
moor-2.10.3/internal/reader/reader.go 0000664 0000000 0000000 00000074052 15135744745 0017447 0 ustar 00root root 0000000 0000000 package reader
import (
"bufio"
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"math"
"os"
"path/filepath"
"runtime/debug"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/textstyles"
"github.com/walles/moor/v2/internal/util"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/lexers"
log "github.com/sirupsen/logrus"
)
// An 1.7MB file took 2s to highlight. The number for this limit is totally
// negotiable.
const MAX_HIGHLIGHT_SIZE int64 = 2_000_000
// To cap resource usage when not needed, start by reading this many lines into
// memory. If the user scrolls near the end or starts searching, we'll read
// more.
//
// Ref: https://github.com/walles/moor/issues/296
const DEFAULT_PAUSE_AFTER_LINES = 50_000
var DisablePlainCachingForBenchmarking = false
type ReaderOptions struct {
// Format JSON input
ShouldFormat bool
// Pause after reading this many lines, unless told otherwise.
// Tune at runtime using SetPauseAfterLines().
//
// nil means 20k lines.
PauseAfterLines *int
// If this is nil, you must call reader.SetStyleForHighlighting() later if
// you want highlighting.
Style *chroma.Style
// If this is set, it will be used as the lexer for highlighting
Lexer chroma.Lexer
}
type Reader interface {
GetLineCount() int
GetLine(index linemetadata.Index) *NumberedLine
// This method will try to honor wantedLineCount over firstLine. This means
// that the returned first line may be different from the requested one.
GetLines(firstLine linemetadata.Index, wantedLineCount int) InputLines
// GetLines gets the indicated lines from the input. The lines will be stored
// in the provided preallocated slice to avoid allocations. The line count is
// determined by the capacity of the provided slice.
//
// The return value is the status text for the returned lines.
GetLinesPreallocated(firstLine linemetadata.Index, resultLines *[]NumberedLine) (string, string)
// False when paused. Showing the paused line count is confusing, because
// the user might think that the number is the total line count, even though
// we are not done yet.
//
// When we're not paused, the number will be constantly changing, indicating
// that the counting is not done yet.
ShouldShowLineCount() bool
}
type Line struct {
raw []byte
plainTextCache atomic.Pointer[string] // Use line.Plain() to access this field
}
// ReaderImpl reads a file into an array of strings.
//
// It does the reading in the background, and it returns parts of the read data
// upon request.
//
// This package provides query methods for the struct, no peeking!!
type ReaderImpl struct {
sync.RWMutex
lines []*Line
// Display name for the buffer. If not set, no buffer name will be shown.
//
// For files, this will be the basename of the file. For our help text, this
// will be "Help". For streams this will generally not be set, but may come
// from the $PAGER_LABEL environment variable.
DisplayName *string
// If this is set, it will point out the file we are reading from. If this
// is not set, we are not reading from a file.
FileName *string
// How many bytes have we read so far?
bytesCount int64
endsWithNewline bool
Err error
// Stream has been completely read. May not be highlighted yet.
ReadingDone *atomic.Bool
// Highlighting has been completed.
HighlightingDone *atomic.Bool
highlightingStyle chan chroma.Style
// This channel expects to be read exactly once. All other uses will lead to
// undefined behavior.
doneWaitingForFirstByte chan bool
// For telling the UI it should recheck the --quit-if-one-screen conditions.
// Signalled when either highlighting is done or reading is done.
MaybeDone chan bool
MoreLinesAdded chan bool
// Because we don't want to consume infinitely.
//
// Ref: https://github.com/walles/moor/issues/296
pauseAfterLines int
pauseAfterLinesUpdated chan bool
// PauseStatus is true if the reader is paused, false if it is not
PauseStatus *atomic.Bool
}
// InputLines contains a number of lines from the reader, plus metadata
type InputLines struct {
Lines []NumberedLine
// "monkey.txt: 1-23/45 51%"
FilenameText string
StatusText string
}
// This is the reader's main function. It will be run in a goroutine. First it
// reads the stream until the end, then starts tailing.
func (reader *ReaderImpl) readStream(stream io.Reader, formatter chroma.Formatter, options ReaderOptions) {
reader.consumeLinesFromStream(stream)
reader.ReadingDone.Store(true)
select {
case reader.MaybeDone <- true:
default:
}
t0 := time.Now()
style := <-reader.highlightingStyle
options.Style = &style
highlightFromMemory(reader, formatter, options)
log.Debug("highlightFromMemory() took ", time.Since(t0))
reader.HighlightingDone.Store(true)
select {
case reader.MaybeDone <- true:
default:
}
// Tail the file if the stream is coming from a file.
// Ref: https://github.com/walles/moor/issues/224
err := reader.tailFile()
if err != nil {
log.Warn("Failed to tail file: ", err)
}
}
// Pause if we should pause, otherwise not. Pausing means waiting for
// pauseAfterLinesUpdated to be signalled in SetPauseAfterLines().
func (reader *ReaderImpl) assumeLockAndMaybePause() {
for {
shouldPause := len(reader.lines) >= reader.pauseAfterLines
if !shouldPause {
// Not there yet, no pause
return
}
// Release lock while pausing
reader.Unlock()
reader.setPauseStatus(true)
<-reader.pauseAfterLinesUpdated
reader.setPauseStatus(false)
reader.Lock()
}
}
// Assume write lock held. Add a new line. If this function paused, it will
// return the pause duration.
func (reader *ReaderImpl) assumeLockAndAddLine(line []byte, considerAppending bool, linePool *linePool) time.Duration {
// Line end
if len(line) > 0 && line[len(line)-1] == '\r' {
line = line[:len(line)-1] // Handle MSDOS line endings
}
if len(reader.lines) == 0 {
// Can't append if there are no previous lines
considerAppending = false
}
if !considerAppending {
newLine := linePool.create(line)
reader.lines = append(reader.lines, newLine)
// New line added, time for a break?
t0 := time.Now()
reader.assumeLockAndMaybePause()
pauseDuration := time.Since(t0)
return pauseDuration
}
// Special case, append to the previous line
baseLine := reader.lines[len(reader.lines)-1]
// Build the complete line
completeLine := make([]byte, len(baseLine.raw)+len(line))
copy(completeLine, baseLine.raw)
copy(completeLine[len(baseLine.raw):], line)
baseLine.raw = completeLine
baseLine.plainTextCache.Store(nil) // Invalidate cache
return 0
}
// This function will update the Reader struct. It is expected to run in a
// goroutine.
//
// It is used both during the initial read of the stream until it ends, and
// while tailing files for changes.
func (reader *ReaderImpl) consumeLinesFromStream(stream io.Reader) {
// This value affects BenchmarkReadLargeFile() performance. Validate changes
// like this:
//
// go test -benchmem -run='^$' -bench 'BenchmarkReadLargeFile' ./internal/reader
const byteBufferSize = 16 * 1024
t0 := time.Now()
// Preallocating the line pool and the lines slice improves large file
// reading performance by 10%.
linePool := linePool{}
if reader.FileName != nil && reader.GetLineCount() == 0 {
lineCount, err := countLines(*reader.FileName)
if err != nil {
log.Warn("Failed to count lines in file: ", err)
} else {
// We have a line count...
reader.Lock()
if len(reader.lines) == 0 {
// ... and still no lines have been read, so preallocate both
// the lines slice...
reader.lines = make([]*Line, 0, lineCount)
// ... and the line pool.
linePool.pool = make([]Line, lineCount)
}
reader.Unlock()
}
}
inspectionReader := inspectionReader{base: stream}
awaitingFirstByte := true
for {
byteBuffer := make([]byte, byteBufferSize)
readBytes, err := inspectionReader.Read(byteBuffer)
if awaitingFirstByte && readBytes > 0 {
// We got our first byte!
select {
case reader.doneWaitingForFirstByte <- true:
default:
}
awaitingFirstByte = false
}
// Error or not, handle the bytes that we got
reader.Lock()
lineStart := 0
byteIndex := 0
for readBytes > 0 {
relativeNewlineLocation := bytes.IndexByte(byteBuffer[byteIndex:readBytes], '\n')
if relativeNewlineLocation == -1 {
// No more newlines in this buffer
break
}
byteIndex += relativeNewlineLocation
considerAppending := lineStart == 0 && !reader.endsWithNewline
pauseDuration := reader.assumeLockAndAddLine(byteBuffer[lineStart:byteIndex], considerAppending, &linePool)
t0 = t0.Add(pauseDuration)
lineStart = byteIndex + 1
byteIndex = lineStart
}
// Handle any remaining bytes as a partial line
if lineStart < readBytes {
considerAppending := lineStart == 0 && !reader.endsWithNewline
pauseDuration := reader.assumeLockAndAddLine(byteBuffer[lineStart:readBytes], considerAppending, &linePool)
t0 = t0.Add(pauseDuration)
}
reader.endsWithNewline = inspectionReader.endedWithNewline
reader.Unlock()
// This is how to do a non-blocking write to a channel:
// https://gobyexample.com/non-blocking-channel-operations
select {
case reader.MoreLinesAdded <- true:
default:
// Default case required for the write to be non-blocking
}
if err == io.EOF {
// Done!
break
}
if err != nil {
reader.Lock()
if reader.Err == nil {
// Store the error unless it overwrites one we already have
reader.Err = fmt.Errorf("error reading from input stream: %w", err)
}
reader.Unlock()
break
}
}
if reader.FileName != nil {
reader.Lock()
reader.bytesCount += inspectionReader.bytesCount
reader.Unlock()
}
if awaitingFirstByte {
// If the stream was empty we never got any first byte. Make sure people
// stop waiting in this case. Async write since it might already have been
// written to.
select {
case reader.doneWaitingForFirstByte <- true:
default:
}
}
log.Info("Stream read in ", time.Since(t0), ", have ", reader.GetLineCount(), " lines")
}
func (reader *ReaderImpl) tailFile() error {
reader.RLock()
fileName := reader.FileName
reader.RUnlock()
if fileName == nil {
return nil
}
log.Debugf("Tailing file %s", *fileName)
for {
// NOTE: We could use something like
// https://github.com/fsnotify/fsnotify instead of sleeping and polling
// here.
time.Sleep(1 * time.Second)
fileStats, err := os.Stat(*fileName)
if err != nil {
log.Debugf("Failed to stat file %s while tailing, giving up: %s", *fileName, err.Error())
return nil
}
reader.RLock()
bytesCount := reader.bytesCount
reader.RUnlock()
if bytesCount == -1 {
log.Debugf("Bytes count unknown for %s, stop tailing", *fileName)
return nil
}
if fileStats.Size() == bytesCount {
log.Tracef("File %s unchanged at %d bytes, continue tailing", *fileName, fileStats.Size())
continue
}
if fileStats.Size() < bytesCount {
log.Debugf("File %s shrunk from %d to %d bytes, stop tailing",
*fileName, bytesCount, fileStats.Size())
return nil
}
// File grew, read the new lines
stream, _, err := ZOpen(*fileName)
if err != nil {
log.Debugf("Failed to open file %s for re-reading while tailing: %s", *fileName, err.Error())
return nil
}
seekable, ok := stream.(io.ReadSeekCloser)
if !ok {
err = stream.Close()
if err != nil {
log.Debugf("Giving up on tailing, failed to close non-seekable stream from %s: %s", *fileName, err.Error())
return nil
}
log.Debugf("Giving up on tailing, file %s is not seekable", *fileName)
return nil
}
_, err = seekable.Seek(bytesCount, io.SeekStart)
if err != nil {
log.Debugf("Failed to seek in file %s while tailing: %s", *fileName, err.Error())
return nil
}
log.Tracef("File %s up from %d bytes to %d bytes, reading more lines...", *fileName, bytesCount, fileStats.Size())
reader.consumeLinesFromStream(seekable)
err = seekable.Close()
if err != nil {
// This can lead to file handle leaks
return fmt.Errorf("failed to close file %s after tailing: %w", *fileName, err)
}
}
}
// NewFromStream creates a new stream reader
//
// The display name can be an empty string ("").
//
// If non-empty, the name will be displayed by the pager in the bottom left
// corner to help the user keep track of what is being paged.
//
// Note that you must call reader.SetStyleForHighlighting() after this to get
// highlighting.
func NewFromStream(displayName string, reader io.Reader, formatter chroma.Formatter, options ReaderOptions) (*ReaderImpl, error) {
zReader, err := ZReader(reader)
if err != nil {
return nil, err
}
mReader := newReaderFromStream(zReader, nil, formatter, options)
if len(displayName) > 0 {
mReader.Lock()
mReader.DisplayName = &displayName
mReader.Unlock()
}
if options.Style != nil {
mReader.SetStyleForHighlighting(*options.Style)
}
return mReader, nil
}
// newReaderFromStream creates a new stream reader
//
// originalFileName is used for counting the lines in the file. nil for
// don't-know (streams) or not countable (compressed files). The line count is
// then used for pre-allocating the lines slice, which improves large file
// loading performance.
//
// If lexer is set, the file will be highlighted after being fully read.
//
// Whatever data we get from the reader, that's what we'll have. Or in other
// words, if the input needs to be decompressed, do that before coming here.
//
// Note that you must call reader.SetStyleForHighlighting() after this to get
// highlighting.
func newReaderFromStream(reader io.Reader, originalFileName *string, formatter chroma.Formatter, options ReaderOptions) *ReaderImpl {
readingDone := atomic.Bool{}
readingDone.Store(false)
highlightingDone := atomic.Bool{}
highlightingDone.Store(false)
pauseStatus := atomic.Bool{}
pauseStatus.Store(false)
pauseAfterLines := DEFAULT_PAUSE_AFTER_LINES
if options.PauseAfterLines != nil {
pauseAfterLines = *options.PauseAfterLines
}
var displayFileName *string
if originalFileName != nil {
basename := filepath.Base(*originalFileName)
displayFileName = &basename
}
returnMe := ReaderImpl{
FileName: originalFileName,
DisplayName: displayFileName,
pauseAfterLines: pauseAfterLines,
pauseAfterLinesUpdated: make(chan bool, 1),
PauseStatus: &pauseStatus,
MoreLinesAdded: make(chan bool, 1),
MaybeDone: make(chan bool, 2),
highlightingStyle: make(chan chroma.Style, 1),
doneWaitingForFirstByte: make(chan bool, 1),
HighlightingDone: &highlightingDone,
ReadingDone: &readingDone,
}
go func() {
defer func() {
PanicHandler("newReaderFromStream()/readStream()", recover(), debug.Stack())
}()
returnMe.readStream(reader, formatter, options)
}()
return &returnMe
}
// Testing only!! May or may not hang if run in real world scenarios.
//
// NewFromTextForTesting creates a Reader from a block of text.
//
// First parameter is the name of this Reader. This name will be displayed by
// Moor in the bottom left corner of the screen.
//
// Calling Wait() on this Reader will always return immediately, no
// asynchronous ops will be performed.
func NewFromTextForTesting(name string, text string) *ReaderImpl {
noExternalNewlines := strings.Trim(text, "\n")
lines := []*Line{}
if len(noExternalNewlines) > 0 {
for _, lineString := range strings.Split(noExternalNewlines, "\n") {
line := Line{raw: []byte(lineString)}
lines = append(lines, &line)
}
}
readingDone := atomic.Bool{}
readingDone.Store(true)
highlightingDone := atomic.Bool{}
highlightingDone.Store(true) // No highlighting to do = nothing left = Done!
returnMe := &ReaderImpl{
lines: lines,
ReadingDone: &readingDone,
HighlightingDone: &highlightingDone,
doneWaitingForFirstByte: make(chan bool, 1),
}
if name != "" {
returnMe.DisplayName = &name
}
return returnMe
}
// Duplicate of moor/moor.go:TryOpen
func TryOpen(filename string) error {
// Try opening the file
tryMe, err := os.Open(filename)
if err != nil {
return err
}
// Try reading a byte
buffer := make([]byte, 1)
_, err = tryMe.Read(buffer)
if err != nil && err.Error() == "EOF" {
// Empty file, this is fine
err = nil
}
closeErr := tryMe.Close()
if err == nil && closeErr != nil {
// Everything worked up until Close(), report the Close() error
return closeErr
}
return err
}
// From: https://stackoverflow.com/a/52153000/473672
func countLines(filename string) (uint64, error) {
const lineBreak = '\n'
sliceWithSingleLineBreak := []byte{lineBreak}
reader, _, err := ZOpen(filename)
if err != nil {
return 0, err
}
defer func() {
err := reader.Close()
if err != nil {
log.Warn("Error closing file after counting the lines: ", err)
}
}()
var count uint64
t0 := time.Now()
buf := make([]byte, bufio.MaxScanTokenSize)
lastReadEndsInNewline := true
for {
bufferSize, err := reader.Read(buf)
if err != nil && err != io.EOF {
return 0, err
}
if bufferSize > 0 {
lastReadEndsInNewline = (buf[bufferSize-1] == lineBreak)
}
count += uint64(bytes.Count(buf[:bufferSize], sliceWithSingleLineBreak))
if err == io.EOF {
break
}
}
if !lastReadEndsInNewline {
// No trailing line feed, this needs special handling
count++
}
t1 := time.Now()
if count == 0 {
log.Debug("Counted ", count, " lines in ", t1.Sub(t0))
} else {
log.Debug("Counted ", count, " lines in ", t1.Sub(t0), " at ", t1.Sub(t0)/time.Duration(count), "/line")
}
return count, nil
}
// NewFromFilename creates a new file reader.
//
// If options.Lexer is nil it will be determined from the input file name.
//
// If options.Style is nil, you must call reader.SetStyleForHighlighting() later
// to get highlighting.
//
// The Reader will try to uncompress various compressed file format, and also
// apply highlighting to the file using Chroma:
// https://github.com/alecthomas/chroma
func NewFromFilename(filename string, formatter chroma.Formatter, options ReaderOptions) (*ReaderImpl, error) {
fileError := TryOpen(filename)
if fileError != nil {
return nil, fileError
}
stream, highlightingFilename, err := ZOpen(filename)
if err != nil {
return nil, err
}
if options.Lexer == nil {
options.Lexer = lexers.Match(highlightingFilename)
}
returnMe := newReaderFromStream(stream, &highlightingFilename, formatter, options)
if options.Lexer == nil {
returnMe.HighlightingDone.Store(true)
}
if options.Style != nil {
returnMe.SetStyleForHighlighting(*options.Style)
}
return returnMe, nil
}
// Wait for reader to finish reading and highlighting. Used by tests.
func (reader *ReaderImpl) Wait() error {
// Wait for our goroutine to finish
for !reader.ReadingDone.Load() {
if reader.PauseStatus.Load() {
// We want more lines
reader.SetPauseAfterLines(reader.GetLineCount() * 2)
}
}
//revive:disable-next-line:empty-block
for !reader.HighlightingDone.Load() {
}
reader.RLock()
defer reader.RUnlock()
return reader.Err
}
func textAsString(reader *ReaderImpl, shouldFormat bool) string {
reader.RLock()
text := []byte{}
for _, line := range reader.lines {
text = append(text, line.raw...)
text = append(text, '\n')
}
reader.RUnlock()
var jsonData any
err := json.Unmarshal(text, &jsonData)
if err != nil {
// Not JSON, return the text as-is
return string(text)
}
if !shouldFormat {
log.Info("Try the --reformat flag for automatic JSON reformatting")
return string(text)
}
// Pretty print the JSON
prettyJSON, err := json.MarshalIndent(jsonData, "", " ")
if err != nil {
log.Debug("Failed to pretty print JSON: ", err)
return string(text)
}
log.Debug("Got the --reformat flag, reformatted JSON input")
return string(prettyJSON)
}
func isXml(text string) bool {
err := xml.Unmarshal([]byte(text), new(any))
return err == nil
}
// We expect this to be executed in a goroutine
func highlightFromMemory(reader *ReaderImpl, formatter chroma.Formatter, options ReaderOptions) {
// Is the buffer small enough?
var byteCount int64
reader.RLock()
for _, line := range reader.lines {
byteCount += int64(len(line.raw))
if byteCount > MAX_HIGHLIGHT_SIZE {
log.Info("File too large for highlighting: ", byteCount)
reader.RUnlock()
return
}
}
reader.RUnlock()
text := textAsString(reader, options.ShouldFormat)
if len(text) == 0 {
log.Debug("Buffer is empty, not highlighting")
return
}
if options.Lexer == nil && json.Valid([]byte(text)) {
log.Info("Buffer is valid JSON, highlighting as JSON")
options.Lexer = lexers.Get("json")
} else if options.Lexer == nil && isXml(text) {
log.Info("Buffer is valid XML, highlighting as XML")
options.Lexer = lexers.Get("xml")
}
if options.Lexer == nil {
log.Debug("No lexer set, not highlighting")
return
}
if options.Style == nil {
log.Debug("No style set, not highlighting")
return
}
if formatter == nil {
log.Debug("No formatter set, not highlighting")
return
}
highlighted, err := Highlight(text, *options.Style, formatter, options.Lexer)
if err != nil {
log.Warn("Highlighting failed: ", err)
return
}
if highlighted == nil {
// No highlighting would be done, never mind
return
}
reader.setText(*highlighted)
}
// createStatusUnlocked() assumes that its caller is holding the read lock
func (reader *ReaderImpl) createStatusUnlocked(lastLine linemetadata.Index) (string, string) {
displayName := ""
if reader.DisplayName != nil {
displayName = *reader.DisplayName
}
if len(reader.lines) == 0 {
empty := ""
if len(displayName) > 0 {
return displayName, ": " + empty
}
return "", empty
}
linesCount := ""
percent := ""
if len(reader.lines) == 1 {
linesCount = "1 line"
percent = "100%"
} else {
// More than one line
linesCount = util.FormatInt(len(reader.lines)) + " lines"
percent = fmt.Sprintf("%.0f%%", math.Floor(100*float64(lastLine.Index()+1)/float64(len(reader.lines))))
}
if !reader.ShouldShowLineCount() {
linesCount = ""
}
return_me := ""
if len(linesCount) > 0 {
if len(displayName) > 0 {
return_me += ": "
}
return_me += linesCount
}
if len(percent) > 0 {
if len(return_me) > 0 {
return_me += " "
}
return_me += percent
}
if len(displayName) > 0 {
return displayName, return_me
}
return "", return_me
}
// Wait for the first line to be read.
//
// Used for making sudo work:
// https://github.com/walles/moor/issues/199
func (reader *ReaderImpl) AwaitFirstByte() {
<-reader.doneWaitingForFirstByte
}
// GetLineCount returns the number of lines available for viewing
func (reader *ReaderImpl) GetLineCount() int {
reader.RLock()
defer reader.RUnlock()
return len(reader.lines)
}
func (reader *ReaderImpl) ShouldShowLineCount() bool {
if reader.ReadingDone.Load() {
// We are done, the number won't change, show it!
return true
}
if !reader.PauseStatus.Load() {
// Reading in progress, number is constantly changing so it's
// obvious we aren't done yet. Show it!
return true
}
return false
}
// The index is for error reporting. Set withCache to false to simulate a cache
// miss for benchmarking.
func (line *Line) Plain(index linemetadata.Index) string {
fromCache := line.plainTextCache.Load()
if DisablePlainCachingForBenchmarking {
// Simulate a cache miss for benchmarking
fromCache = nil
}
if fromCache != nil {
return *fromCache
}
plain := textstyles.StripFormatting(string(line.raw), index)
// If this succeeds, all good. If it fails it means some other goroutine
// populated the cache before us, which is also fine.
_ = line.plainTextCache.CompareAndSwap(nil, &plain)
return plain
}
// GetLine gets a line. If the requested line number is out of bounds, nil is returned.
func (reader *ReaderImpl) GetLine(index linemetadata.Index) *NumberedLine {
reader.RLock()
if index.Index() >= reader.pauseAfterLines-DEFAULT_PAUSE_AFTER_LINES/2 {
// Switch to the write lock for changing the pause threshold
reader.RUnlock()
reader.Lock()
// Getting close(ish) to the pause threshold, bump it up. The Max()
// construct is to handle the case when the add overflows.
reader.pauseAfterLines = slices.Max([]int{
reader.pauseAfterLines + DEFAULT_PAUSE_AFTER_LINES/2,
reader.pauseAfterLines})
select {
case reader.pauseAfterLinesUpdated <- true:
default:
// Default case required for the write to be non-blocking
}
// Back to read lock for the rest of this function
reader.Unlock()
reader.RLock()
}
if !index.IsWithinLength(len(reader.lines)) {
reader.RUnlock()
return nil
}
returnLine := reader.lines[index.Index()]
reader.RUnlock()
return &NumberedLine{
Index: index,
Number: linemetadata.NumberFromZeroBased(index.Index()),
Line: returnLine,
}
}
// Given a starting point and a count, return a start and end index that don't
// exceed maxIndex. On overflow, the requested range will be shifted backwards
// to fit within maxIndex, and if that's not enough, cut at the end.
func clipRangeToLength(start linemetadata.Index, wantedCount int, maxIndex int) (int, int) {
if wantedCount <= 0 {
panic(fmt.Sprintf("wantedCount must be at least 1, was %d", wantedCount))
}
if maxIndex < 0 {
panic(fmt.Sprintf("maxIndex must be at least 0, was %d", maxIndex))
}
first := start.Index()
// Cap wantedCount to the available length (maxIndex+1).
available := maxIndex + 1
if wantedCount > available {
wantedCount = available
}
// Clamp start so the window fits: start <= maxIndex - (wantedCount - 1)
highestStart := maxIndex - (wantedCount - 1)
if first > highestStart {
first = highestStart
}
if first < 0 {
first = 0
}
last := first + wantedCount - 1
if last > maxIndex {
last = maxIndex
}
return first, last
}
// GetLines gets the indicated lines from the input
func (reader *ReaderImpl) GetLines(firstLine linemetadata.Index, wantedLineCount int) InputLines {
reader.RLock()
lineCount := len(reader.lines)
if lineCount == 0 || wantedLineCount == 0 {
filenameText, statusText := reader.createStatusUnlocked(firstLine)
reader.RUnlock()
return InputLines{
FilenameText: filenameText,
StatusText: statusText,
}
}
reader.RUnlock()
firstLineIndex, lastLineIndex := clipRangeToLength(firstLine, wantedLineCount, lineCount-1)
wantedLineCount = lastLineIndex - firstLineIndex + 1
resultLines := make([]NumberedLine, 0, wantedLineCount)
filenameText, statusText := reader.GetLinesPreallocated(linemetadata.IndexFromZeroBased(firstLineIndex), &resultLines)
return InputLines{
Lines: resultLines,
FilenameText: filenameText,
StatusText: statusText,
}
}
// GetLines gets the indicated lines from the input. The lines will be stored
// in the provided preallocated slice to avoid allocations. The line count is
// determined by the capacity of the provided slice.
//
// The return value is the status text for the returned lines.
func (reader *ReaderImpl) GetLinesPreallocated(firstLine linemetadata.Index, resultLines *[]NumberedLine) (string, string) {
// Clear the result slice
*resultLines = (*resultLines)[:0]
reader.RLock()
if len(reader.lines) == 0 || cap(*resultLines) == 0 {
filenameText, statusText := reader.createStatusUnlocked(firstLine)
reader.RUnlock()
return filenameText, statusText
}
// Prevent reading past the end of the available lines
firstLineIndex, lastLineIndex := clipRangeToLength(firstLine, cap(*resultLines), len(reader.lines)-1)
filenameText, statusText := reader.createStatusUnlocked(linemetadata.IndexFromZeroBased(lastLineIndex))
for loopIndex, returnLine := range reader.lines[firstLineIndex : lastLineIndex+1] {
*resultLines = append(*resultLines, NumberedLine{
Index: linemetadata.IndexFromZeroBased(firstLineIndex + loopIndex),
Number: linemetadata.NumberFromZeroBased(firstLineIndex + loopIndex),
Line: returnLine,
})
}
reader.RUnlock()
return filenameText, statusText
}
func (reader *ReaderImpl) PumpToStdout() {
const wantedLineCount = 100
firstNotPrintedLine := linemetadata.Index{}
drainLines := func() bool {
lines := reader.GetLines(firstNotPrintedLine, wantedLineCount)
var firstReturnedIndex linemetadata.Index
if len(lines.Lines) > 0 {
firstReturnedIndex = lines.Lines[0].Index
}
// Print the lines we got
printed := false
for loopIndex, line := range lines.Lines {
lineIndex := firstReturnedIndex.NonWrappingAdd(loopIndex)
if lineIndex.IsBefore(firstNotPrintedLine) {
continue
}
fmt.Println(string(line.Line.raw))
printed = true
firstNotPrintedLine = lineIndex.NonWrappingAdd(1)
}
return printed
}
drainAllLines := func() {
for drainLines() {
// Loop here until nothing was printed
}
}
done := false
for !done {
drainAllLines()
select {
case <-reader.MoreLinesAdded:
continue
case <-reader.MaybeDone:
done = true
}
}
// Print any remaining lines
drainAllLines()
}
// Replace reader contents with the given text. Consider setting
// HighlightingDone and signalling the MaybeDone channel afterwards.
func (reader *ReaderImpl) setText(text string) {
lines := []*Line{}
for _, lineString := range strings.Split(text, "\n") {
line := Line{raw: []byte(lineString)}
lines = append(lines, &line)
}
if len(lines) > 0 && strings.HasSuffix(text, "\n") {
// Input ends with an empty line. This makes our line count be
// off-by-one, fix that!
lines = lines[0 : len(lines)-1]
}
reader.Lock()
reader.lines = lines
reader.Unlock()
log.Trace("Reader done, contents explicitly set")
select {
case reader.MoreLinesAdded <- true:
default:
}
}
func (reader *ReaderImpl) setPauseStatus(paused bool) {
if !reader.PauseStatus.CompareAndSwap(!paused, paused) {
// Pause status already had that value, we're done
return
}
log.Debugf("Reader pause status changed to %t", paused)
}
func (reader *ReaderImpl) SetPauseAfterLines(lines int) {
if lines < 0 {
log.Warnf("Tried to set pause-after-lines to %d, ignoring", lines)
return
}
log.Trace("Setting pause-after-lines to ", lines, "...")
reader.Lock()
reader.pauseAfterLines = lines
reader.Unlock()
// Notify the reader that the pause-after-lines value has been updated. Will
// be noticed in the maybePause() function.
select {
case reader.pauseAfterLinesUpdated <- true:
default:
// Default case required for the write to be non-blocking
}
}
func (reader *ReaderImpl) SetStyleForHighlighting(style chroma.Style) {
reader.highlightingStyle <- style
}
moor-2.10.3/internal/reader/reader_pause_test.go 0000664 0000000 0000000 00000010512 15135744745 0021672 0 ustar 00root root 0000000 0000000 package reader
import (
"os"
"strings"
"testing"
"time"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/styles"
"github.com/walles/moor/v2/internal/linemetadata"
"gotest.tools/v3/assert"
)
func TestPauseAfterNLines(t *testing.T) {
pauseAfterLines := 1
// Get ourselves a reader
twoLines := strings.NewReader("one\ntwo\n")
testMe, err := NewFromStream(
"TestPauseAfterNLines",
twoLines,
formatters.TTY,
ReaderOptions{
PauseAfterLines: &pauseAfterLines,
Style: styles.Get("native"),
})
assert.NilError(t, err)
// Expect pause since we configured the reader to pause after 1 line ^
for testMe.PauseStatus.Load() != true {
}
assert.Assert(t, testMe.PauseStatus.Load() == true,
"Reader should be paused after reading %d lines", pauseAfterLines)
// Verify that we have *not* received a done notification yet
assert.Assert(t, testMe.ReadingDone.Load() == false,
"Reader should not be done yet, only paused")
// Check that the reader has exactly the first line and nothing else
lines := testMe.GetLines(linemetadata.Index{}, 2).Lines
assert.Equal(t, len(lines), 1,
"Reader should have exactly one line after pausing")
assert.Equal(t, lines[0].Plain(), "one",
"Reader should have the first line after pausing")
// Tell reader to continue
testMe.SetPauseAfterLines(99)
// More lines allowed, expect pause to be over
for testMe.PauseStatus.Load() != false {
}
assert.Assert(t, testMe.PauseStatus.Load() == false,
"Reader should be unpaused after continuing")
// Expect a done notification
<-testMe.MaybeDone
assert.Assert(t, testMe.ReadingDone.Load() == true,
"Reader should be done after reading all lines")
// Check that the reader has both lines
lines = testMe.GetLines(linemetadata.Index{}, 3).Lines
assert.Equal(t, len(lines), 2,
"Reader should have two lines after unpausing")
assert.Equal(t, lines[0].Plain(), "one",
"Reader should have the first line after unpausing")
assert.Equal(t, lines[1].Plain(), "two",
"Reader should have the second line after unpausing")
}
// Test pausing behavior after we're done reading from a file, and then another line is added.
func TestPauseAfterNLines_Polling(t *testing.T) {
pauseAfterLines := 1
// Create a file with a line in it
file, err := os.CreateTemp("", "TestPauseAfterNLines_Polling")
assert.NilError(t, err)
defer os.Remove(file.Name()) //nolint:errcheck
_, err = file.WriteString("one\n")
assert.NilError(t, err)
// Point a reader at the file
testMe, err := NewFromFilename(file.Name(), formatters.TTY, ReaderOptions{
PauseAfterLines: &pauseAfterLines,
Style: styles.Get("native"),
})
assert.NilError(t, err)
// Expect pause since we configured the reader to pause after 1 line ^
for testMe.PauseStatus.Load() != true {
}
assert.Assert(t, testMe.PauseStatus.Load() == true,
"Reader should be paused after reading %d lines", pauseAfterLines)
// Verify state before we add another line to the file
lines := testMe.GetLines(linemetadata.Index{}, 2).Lines
assert.Equal(t, len(lines), 1,
"Reader should have exactly one line after pausing")
assert.Equal(t, lines[0].Plain(), "one",
"Reader should have the first line after pausing")
// Write another line to the file
_, err = file.WriteString("two\n")
assert.NilError(t, err)
// Wait up to two seconds for tailFile() to give us the new line even though
// we are paused. That shouldn't happen. If it does we fail here.
//
// tailFile() polls every second, so two seconds should cover it.
for range 20 {
allLines := testMe.GetLines(linemetadata.Index{}, 10)
if len(allLines.Lines) == 2 {
assert.Assert(t, false, "Reader should not have received a new line while paused")
}
time.Sleep(100 * time.Millisecond)
}
// No new line while paused, good! Unpause.
testMe.SetPauseAfterLines(99)
// Give the new line two seconds to arrive
var bothLines []NumberedLine
for range 20 {
bothLines = testMe.GetLines(linemetadata.Index{}, 10).Lines
if len(bothLines) > 1 {
break
}
time.Sleep(100 * time.Millisecond)
}
// Verify that we have both lines now
assert.Equal(t, len(bothLines), 2,
"Reader should have two lines after unpausing")
assert.Equal(t, bothLines[0].Plain(), "one",
"Reader should have the first line after unpausing")
assert.Equal(t, bothLines[1].Plain(), "two",
"Reader should have the second line after unpausing")
}
moor-2.10.3/internal/reader/reader_test.go 0000664 0000000 0000000 00000052500 15135744745 0020500 0 ustar 00root root 0000000 0000000 package reader
import (
"math"
"os"
"os/exec"
"path"
"runtime"
"strconv"
"strings"
"testing"
"time"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
log "github.com/sirupsen/logrus"
"gotest.tools/v3/assert"
"github.com/walles/moor/v2/internal/linemetadata"
)
const samplesDir = "../../sample-files"
func init() {
// Info logs clutter at least benchmark output
log.SetLevel(log.WarnLevel)
}
func testGetLineCount(t *testing.T, reader *ReaderImpl) {
if strings.Contains(*reader.DisplayName, "compressed") {
// We are no good at counting lines of compressed files, never mind
return
}
cmd := exec.Command("wc", "-l", *reader.FileName)
output, err := cmd.CombinedOutput()
if err != nil {
t.Error("Error calling wc -l to count lines of", *reader.FileName, err)
}
wcNumberString := strings.Split(strings.TrimSpace(string(output)), " ")[0]
wcLineCount, err := strconv.Atoi(wcNumberString)
if err != nil {
t.Error("Error counting lines of", *reader.FileName, err)
}
// wc -l under-counts by 1 if the file doesn't end in a newline
rawBytes, err := os.ReadFile(*reader.FileName)
if err == nil && len(rawBytes) > 0 && rawBytes[len(rawBytes)-1] != '\n' {
wcLineCount++
}
if reader.GetLineCount() != wcLineCount {
t.Errorf("Got %d lines from the reader but %d lines from wc -l: <%s>",
reader.GetLineCount(), wcLineCount, *reader.FileName)
}
countLinesCount, err := countLines(*reader.FileName)
assert.NilError(t, err)
if countLinesCount != uint64(wcLineCount) {
t.Errorf("Got %d lines from wc -l, but %d lines from our countLines() function", wcLineCount, countLinesCount)
}
}
func firstLine(inputLines InputLines) linemetadata.Index {
return inputLines.Lines[0].Index
}
func testGetLines(t *testing.T, reader *ReaderImpl) {
lines := reader.GetLines(linemetadata.Index{}, 10)
if len(lines.Lines) > 10 {
t.Errorf("Asked for 10 lines, got too many: %d", len(lines.Lines))
}
if len(lines.Lines) < 10 {
// No good plan for how to test short files, more than just
// querying them, which we just did
return
}
// Test clipping at the end
lines = reader.GetLines(linemetadata.IndexMax(), 10)
if len(lines.Lines) != 10 {
t.Errorf("Asked for 10 lines but got %d", len(lines.Lines))
return
}
startOfLastSection := firstLine(lines)
lines = reader.GetLines(startOfLastSection, 10)
if firstLine(lines) != startOfLastSection {
t.Errorf("Expected start line %d when asking for the last 10 lines, got %s",
startOfLastSection, firstLine(lines).Format())
return
}
if len(lines.Lines) != 10 {
t.Errorf("Expected 10 lines when asking for the last 10 lines, got %d",
len(lines.Lines))
return
}
lines = reader.GetLines(startOfLastSection.NonWrappingAdd(1), 10)
if firstLine(lines) != startOfLastSection {
t.Errorf("Expected start line %d when asking for the last+1 10 lines, got %s",
startOfLastSection, firstLine(lines).Format())
return
}
if len(lines.Lines) != 10 {
t.Errorf("Expected 10 lines when asking for the last+1 10 lines, got %d",
len(lines.Lines))
return
}
lines = reader.GetLines(startOfLastSection.NonWrappingAdd(-1), 10)
if firstLine(lines) != startOfLastSection.NonWrappingAdd(-1) {
t.Errorf("Expected start line %d when asking for the last-1 10 lines, got %s",
startOfLastSection, firstLine(lines).Format())
return
}
if len(lines.Lines) != 10 {
t.Errorf("Expected 10 lines when asking for the last-1 10 lines, got %d",
len(lines.Lines))
return
}
}
func getTestFiles(t *testing.T) []string {
files, err := os.ReadDir(samplesDir)
assert.NilError(t, err)
var filenames []string
for _, file := range files {
filenames = append(filenames, path.Join(samplesDir, file.Name()))
}
return filenames
}
func TestGetLines(t *testing.T) {
for _, file := range getTestFiles(t) {
t.Run(file, func(t *testing.T) {
reader, err := NewFromFilename(file, formatters.TTY16m, ReaderOptions{Style: styles.Get("native")})
if err != nil {
t.Errorf("Error opening file <%s>: %s", file, err.Error())
return
}
if err := reader.Wait(); err != nil {
t.Errorf("Error reading file <%s>: %s", file, err.Error())
return
}
t.Run(file, func(t *testing.T) {
testGetLines(t, reader)
testGetLineCount(t, reader)
testHighlightingLineCount(t, file)
})
})
}
}
func testHighlightingLineCount(t *testing.T, filenameWithPath string) {
// This won't work on compressed files
if strings.HasSuffix(filenameWithPath, ".xz") {
return
}
if strings.HasSuffix(filenameWithPath, ".bz2") {
return
}
if strings.HasSuffix(filenameWithPath, ".gz") {
return
}
if strings.HasSuffix(filenameWithPath, ".zst") {
return
}
if strings.HasSuffix(filenameWithPath, ".zstd") {
return
}
// Load the unformatted file
rawBytes, err := os.ReadFile(filenameWithPath)
assert.NilError(t, err)
rawContents := string(rawBytes)
// Count its lines
rawLinefeedsCount := strings.Count(rawContents, "\n")
rawRunes := []rune(rawContents)
rawFileEndsWithNewline := true // Special case empty files
if len(rawRunes) > 0 {
rawFileEndsWithNewline = rawRunes[len(rawRunes)-1] == '\n'
}
rawLinesCount := rawLinefeedsCount
if !rawFileEndsWithNewline {
rawLinesCount++
}
// Then load the same file using one of our Readers
reader, err := NewFromFilename(filenameWithPath, formatters.TTY16m, ReaderOptions{Style: styles.Get("native")})
assert.NilError(t, err)
err = reader.Wait()
assert.NilError(t, err)
highlightedLinesCount := reader.GetLineCount()
assert.Equal(t, rawLinesCount, highlightedLinesCount)
}
func TestGetLongLine(t *testing.T) {
file := samplesDir + "/very-long-line.txt"
reader, err := NewFromFilename(file, formatters.TTY16m, ReaderOptions{Style: styles.Get("native")})
assert.NilError(t, err)
assert.NilError(t, reader.Wait())
lines := reader.GetLines(linemetadata.Index{}, 5)
assert.Equal(t, firstLine(lines), linemetadata.Index{})
assert.Equal(t, len(lines.Lines), 1)
line := lines.Lines[0]
assert.Assert(t, strings.HasPrefix(line.Plain(), "1 2 3 4"), "<%s>", line.Plain())
assert.Assert(t, strings.HasSuffix(line.Plain(), "0123456789"), line.Plain())
assert.Equal(t, len(line.Plain()), 100021)
}
func getReaderWithLineCount(totalLines int) *ReaderImpl {
return NewFromTextForTesting("", strings.Repeat("x\n", totalLines))
}
func testStatusText(t *testing.T, fromLine linemetadata.Index, toLine linemetadata.Index, totalLines int, expected string) {
testMe := getReaderWithLineCount(totalLines)
linesRequested := fromLine.CountLinesTo(toLine)
lines := testMe.GetLines(fromLine, linesRequested)
statusText := lines.StatusText
assert.Equal(t, statusText, expected)
}
func TestStatusText(t *testing.T) {
testStatusText(t, linemetadata.Index{}, linemetadata.IndexFromOneBased(10), 20, "20 lines 50%")
testStatusText(t, linemetadata.Index{}, linemetadata.IndexFromOneBased(5), 5, "5 lines 100%")
testStatusText(t,
linemetadata.IndexFromOneBased(998),
linemetadata.IndexFromOneBased(999),
1000,
"1000 lines 99%")
testStatusText(t, linemetadata.Index{}, linemetadata.Index{}, 0, "")
testStatusText(t, linemetadata.Index{}, linemetadata.Index{}, 1, "1 line 100%")
// Test with filename
testMe, err := NewFromFilename(samplesDir+"/empty", formatters.TTY16m, ReaderOptions{Style: styles.Get("native")})
assert.NilError(t, err)
assert.NilError(t, testMe.Wait())
line := testMe.GetLines(linemetadata.Index{}, 0)
if line.Lines != nil {
t.Error("line.lines is should have been nil when reading from an empty stream")
}
assert.Equal(t, line.FilenameText, "empty")
assert.Equal(t, line.StatusText, ": ")
}
func testCompressedFile(t *testing.T, filename string) {
filenameWithPath := path.Join(samplesDir, filename)
reader, e := NewFromFilename(filenameWithPath, formatters.TTY16m, ReaderOptions{Style: styles.Get("native")})
if e != nil {
t.Errorf("Error opening file <%s>: %s", filenameWithPath, e.Error())
panic(e)
}
assert.NilError(t, reader.Wait())
lines := reader.GetLines(linemetadata.Index{}, 5)
assert.Equal(t, lines.Lines[0].Plain(), "This is a compressed file", "%s", filename)
}
func TestCompressedFiles(t *testing.T) {
testCompressedFile(t, "compressed.txt.gz")
testCompressedFile(t, "compressed.txt.bz2")
testCompressedFile(t, "compressed.txt.xz")
testCompressedFile(t, "compressed.txt.zst")
testCompressedFile(t, "compressed.txt.zstd")
}
func TestReadFileDoneNoHighlighting(t *testing.T) {
testMe, err := NewFromFilename(samplesDir+"/empty",
formatters.TTY, ReaderOptions{Style: styles.Get("native")})
assert.NilError(t, err)
assert.NilError(t, testMe.Wait())
}
func TestReadFileDoneYesHighlighting(t *testing.T) {
testMe, err := NewFromFilename("reader_test.go",
formatters.TTY, ReaderOptions{Style: styles.Get("native")})
assert.NilError(t, err)
assert.NilError(t, testMe.Wait())
}
func TestReadStreamDoneNoHighlighting(t *testing.T) {
testMe, err := NewFromStream("", strings.NewReader("Johan"), nil, ReaderOptions{Style: &chroma.Style{}})
assert.NilError(t, err)
assert.NilError(t, testMe.Wait())
}
func TestReadStreamDoneYesHighlighting(t *testing.T) {
testMe, err := NewFromStream("",
strings.NewReader("Johan"),
formatters.TTY, ReaderOptions{Lexer: lexers.EmacsLisp, Style: styles.Get("native")})
assert.NilError(t, err)
assert.NilError(t, testMe.Wait())
}
func TestReadTextDone(t *testing.T) {
testMe := NewFromTextForTesting("", "Johan")
assert.NilError(t, testMe.Wait())
}
// JSON should be auto detected and formatted
func TestFormatJson(t *testing.T) {
// Note the space after "key" to verify formatting actually happens
jsonStream := strings.NewReader(`{"key" :"value"}`)
testMe, err := NewFromStream(
"JSON test",
jsonStream,
formatters.TTY,
ReaderOptions{
Style: styles.Get("native"),
ShouldFormat: true,
})
assert.NilError(t, err)
assert.NilError(t, testMe.Wait())
lines := testMe.GetLines(linemetadata.Index{}, 10)
assert.Equal(t, lines.Lines[0].Plain(), "{")
assert.Equal(t, lines.Lines[1].Plain(), ` "key": "value"`)
assert.Equal(t, lines.Lines[2].Plain(), "}")
assert.Equal(t, len(lines.Lines), 3)
}
func TestFormatJsonArray(t *testing.T) {
// Note the space after "key" to verify formatting actually happens
jsonStream := strings.NewReader(`[{"key" :"value"}]`)
testMe, err := NewFromStream(
"JSON test",
jsonStream,
formatters.TTY,
ReaderOptions{
Style: styles.Get("native"),
ShouldFormat: true,
})
assert.NilError(t, err)
assert.NilError(t, testMe.Wait())
lines := testMe.GetLines(linemetadata.Index{}, 10)
assert.Equal(t, lines.Lines[0].Plain(), "[")
assert.Equal(t, lines.Lines[1].Plain(), " {")
assert.Equal(t, lines.Lines[2].Plain(), ` "key": "value"`)
assert.Equal(t, lines.Lines[3].Plain(), " }")
assert.Equal(t, lines.Lines[4].Plain(), "]")
assert.Equal(t, len(lines.Lines), 5)
}
// If people keep appending to the currently opened file we should display those
// changes.
func TestReadUpdatingFile(t *testing.T) {
// Make a temp file containing one line of text, ending with a newline
file, err := os.CreateTemp("", "moor-TestReadUpdatingFile-*.txt")
assert.NilError(t, err)
defer os.Remove(file.Name()) //nolint:errcheck
const firstLineString = "First line\n"
_, err = file.WriteString(firstLineString)
assert.NilError(t, err)
// Start a reader on that file
testMe, err := NewFromFilename(file.Name(), formatters.TTY16m, ReaderOptions{Style: styles.Get("native")})
assert.NilError(t, err)
// Wait for the reader to finish reading
assert.NilError(t, testMe.Wait())
assert.Equal(t, len([]byte(firstLineString)), int(testMe.bytesCount))
// Verify we got the single line
allLines := testMe.GetLines(linemetadata.Index{}, 10)
assert.Equal(t, len(allLines.Lines), 1)
assert.Equal(t, testMe.GetLineCount(), 1)
assert.Equal(t, allLines.Lines[0].Plain(), "First line")
// Append a line to the file
const secondLineString = "Second line\n"
_, err = file.WriteString(secondLineString)
assert.NilError(t, err)
// Give the reader some time to react
for range 20 {
allLines := testMe.GetLines(linemetadata.Index{}, 10)
if len(allLines.Lines) == 2 {
break
}
time.Sleep(100 * time.Millisecond)
}
// Verify we got the two lines
allLines = testMe.GetLines(linemetadata.Index{}, 10)
assert.Equal(t, len(allLines.Lines), 2, "Expected two lines after adding a second one, got %d", len(allLines.Lines))
assert.Equal(t, testMe.GetLineCount(), 2)
assert.Equal(t, allLines.Lines[0].Plain(), "First line")
assert.Equal(t, allLines.Lines[1].Plain(), "Second line")
assert.Equal(t, int(testMe.bytesCount), len([]byte(firstLineString+secondLineString)))
// Append a third line to the file. We want to verify line 2 didn't just
// succeed due to special handling.
const thirdLineString = "Third line\n"
_, err = file.WriteString(thirdLineString)
assert.NilError(t, err)
// Give the reader some time to react
for i := 0; i < 20; i++ {
allLines = testMe.GetLines(linemetadata.Index{}, 10)
if len(allLines.Lines) == 3 {
break
}
time.Sleep(100 * time.Millisecond)
}
// Verify we got all three lines
allLines = testMe.GetLines(linemetadata.Index{}, 10)
assert.Equal(t, len(allLines.Lines), 3, "Expected three lines after adding a third one, got %d", len(allLines.Lines))
assert.Equal(t, testMe.GetLineCount(), 3)
assert.Equal(t, allLines.Lines[0].Plain(), "First line")
assert.Equal(t, allLines.Lines[1].Plain(), "Second line")
assert.Equal(t, allLines.Lines[2].Plain(), "Third line")
assert.Equal(t, int(testMe.bytesCount), len([]byte(firstLineString+secondLineString+thirdLineString)))
}
// If people keep appending to the currently opened file we should display those
// changes.
//
// This test verifies it with an initially empty file.
func TestReadUpdatingFile_InitiallyEmpty(t *testing.T) {
// Make a temp file containing one line of text, ending with a newline
file, err := os.CreateTemp("", "moor-TestReadUpdatingFile_NoNewlineAtEOF-*.txt")
assert.NilError(t, err)
defer os.Remove(file.Name()) //nolint:errcheck
// Start a reader on that file
testMe, err := NewFromFilename(file.Name(), formatters.TTY16m, ReaderOptions{Style: styles.Get("native")})
assert.NilError(t, err)
// Wait for the reader to finish reading
assert.NilError(t, testMe.Wait())
// Verify no lines
allLines := testMe.GetLines(linemetadata.Index{}, 10)
assert.Equal(t, len(allLines.Lines), 0)
assert.Equal(t, testMe.GetLineCount(), 0)
// Append a line to the file
_, err = file.WriteString("Text\n")
assert.NilError(t, err)
// Give the reader some time to react
for i := 0; i < 20; i++ {
allLines := testMe.GetLines(linemetadata.Index{}, 10)
if len(allLines.Lines) == 1 {
break
}
time.Sleep(100 * time.Millisecond)
}
// Verify we got the two lines
allLines = testMe.GetLines(linemetadata.Index{}, 10)
assert.Equal(t, len(allLines.Lines), 1, "Expected one line after adding one, got %d", len(allLines.Lines))
assert.Equal(t, testMe.GetLineCount(), 1)
assert.Equal(t, allLines.Lines[0].Plain(), "Text")
}
// If people keep appending to the currently opened file we should display those
// changes.
//
// This test verifies it with the initial contents not ending with a linefeed.
func TestReadUpdatingFile_HalfLine(t *testing.T) {
// Make a temp file containing one line of text, ending with a newline
file, err := os.CreateTemp("", "moor-TestReadUpdatingFile-*.txt")
assert.NilError(t, err)
defer os.Remove(file.Name()) //nolint:errcheck
_, err = file.WriteString("Start")
assert.NilError(t, err)
// Start a reader on that file
testMe, err := NewFromFilename(file.Name(), formatters.TTY16m, ReaderOptions{Style: styles.Get("native")})
assert.NilError(t, err)
// Wait for the reader to finish reading
assert.NilError(t, testMe.Wait())
assert.Equal(t, int(testMe.bytesCount), len([]byte("Start")))
// Append the rest of the line
const secondLineString = ", end\n"
_, err = file.WriteString(secondLineString)
assert.NilError(t, err)
// Give the reader some time to react
for i := 0; i < 20; i++ {
allLines := testMe.GetLines(linemetadata.Index{}, 10)
if len(allLines.Lines) == 2 {
break
}
time.Sleep(100 * time.Millisecond)
}
// Verify we got the two lines
allLines := testMe.GetLines(linemetadata.Index{}, 10)
assert.Equal(t, len(allLines.Lines), 1, "Still expecting one line, got %d", len(allLines.Lines))
assert.Equal(t, testMe.GetLineCount(), 1)
assert.Equal(t, allLines.Lines[0].Plain(), "Start, end")
assert.Equal(t, int(testMe.bytesCount), len([]byte("Start, end\n")))
}
// If people keep appending to the currently opened file we should display those
// changes.
//
// This test verifies it with the initial contents ending in the middle of an UTF-8 character.
func TestReadUpdatingFile_HalfUtf8(t *testing.T) {
// Make a temp file containing one line of text, ending with a newline
file, err := os.CreateTemp("", "moor-TestReadUpdatingFile-*.txt")
assert.NilError(t, err)
defer os.Remove(file.Name()) //nolint:errcheck
// Write "h" and half an "ä" to the file
_, err = file.Write([]byte("här"[0:2]))
assert.NilError(t, err)
// Start a reader on that file
testMe, err := NewFromFilename(file.Name(), formatters.TTY16m, ReaderOptions{Style: styles.Get("native")})
assert.NilError(t, err)
// Wait for the reader to finish reading
assert.NilError(t, testMe.Wait())
assert.Equal(t, testMe.GetLineCount(), 1)
// Append the rest of the UTF-8 character
_, err = file.WriteString("här"[2:])
assert.NilError(t, err)
// Give the reader some time to react
for range 20 {
allLines := testMe.GetLines(linemetadata.Index{}, 10)
if len(allLines.Lines) == 2 {
break
}
time.Sleep(100 * time.Millisecond)
}
// Verify we got the two lines
allLines := testMe.GetLines(linemetadata.Index{}, 10)
assert.Equal(t, len(allLines.Lines), 1, "Still expecting one line, got %d", len(allLines.Lines))
assert.Equal(t, testMe.GetLineCount(), 1)
assert.Equal(t, allLines.Lines[0].Plain(), "här")
assert.Equal(t, int(testMe.bytesCount), len([]byte("här")))
}
func TestClipRangeToLength(t *testing.T) {
// Within bounds
i0, i1 := clipRangeToLength(linemetadata.Index{}, 1, 20)
assert.Equal(t, i0, 0)
assert.Equal(t, i1, 0)
// Touching the end, still within bounds
i0, i1 = clipRangeToLength(linemetadata.Index{}, 1, 0)
assert.Equal(t, i0, 0)
assert.Equal(t, i1, 0)
// Overflow, push down to indices 6, 7, 8
i0, i1 = clipRangeToLength(linemetadata.IndexFromOneBased(100), 3, 8)
assert.Equal(t, i0, 6)
assert.Equal(t, i1, 8)
// Overflow, push down and clip to indices 0, 1
i0, i1 = clipRangeToLength(linemetadata.IndexFromOneBased(100), 3, 1)
assert.Equal(t, i0, 0)
assert.Equal(t, i1, 1)
// Maxed out start
i0, i1 = clipRangeToLength(linemetadata.IndexMax(), 1, 0)
assert.Equal(t, i0, 0)
assert.Equal(t, i1, 0)
// Maxed out count
i0, i1 = clipRangeToLength(linemetadata.Index{}, math.MaxInt, 0)
assert.Equal(t, i0, 0)
assert.Equal(t, i1, 0)
// Maxed out start and count
i0, i1 = clipRangeToLength(linemetadata.IndexMax(), math.MaxInt, 3)
assert.Equal(t, i0, 0)
assert.Equal(t, i1, 3)
}
// How long does it take to read a file?
//
// This can be slow due to highlighting.
//
// Run with: go test -run='^$' -bench=. . ./...
func BenchmarkReaderDone(b *testing.B) {
filename := "reader.go" // This is our longest .go file
b.ResetTimer()
for n := 0; n < b.N; n++ {
// This is our longest .go file
readMe, err := NewFromFilename(filename, formatters.TTY16m, ReaderOptions{Style: styles.Get("native")})
assert.NilError(b, err)
assert.NilError(b, readMe.Wait())
assert.NilError(b, readMe.Err)
}
}
// Try loading a large file
func BenchmarkReadLargeFile(b *testing.B) {
// Try loading a file this large
const largeSizeBytes = 35_000_000
// First, create it from something...
inputFilename := "reader.go"
contents, err := os.ReadFile(inputFilename)
assert.NilError(b, err)
testdir := b.TempDir()
largeFileName := testdir + "/large-file"
largeFile, err := os.Create(largeFileName)
assert.NilError(b, err)
totalBytesWritten := 0
for totalBytesWritten < largeSizeBytes {
written, err := largeFile.Write(contents)
assert.NilError(b, err)
totalBytesWritten += written
}
err = largeFile.Close()
assert.NilError(b, err)
// Make sure we don't pause during the benchmark
targetLineCount := largeSizeBytes * 2
b.SetBytes(int64(totalBytesWritten))
// Try making the whole run more predictable
runtime.GC()
b.ResetTimer()
for n := 0; n < b.N; n++ {
readMe, err := NewFromFilename(
largeFileName,
formatters.TTY16m,
ReaderOptions{
Style: styles.Get("native"),
PauseAfterLines: &targetLineCount,
})
assert.NilError(b, err)
<-readMe.MaybeDone
assert.NilError(b, readMe.Wait())
assert.NilError(b, readMe.Err)
}
}
// Count lines in pager.go
func BenchmarkCountLines(b *testing.B) {
// First, get some sample lines...
inputFilename := "reader.go"
contents, err := os.ReadFile(inputFilename)
assert.NilError(b, err)
testdir := b.TempDir()
countFileName := testdir + "/count-file"
countFile, err := os.Create(countFileName)
assert.NilError(b, err)
// Make a large enough test case that a majority of the time is spent
// counting lines, rather than on any counting startup cost.
//
// We used to have 1000 here, but that made the benchmark result fluctuate
// too much. 10_000 seems to provide stable enough results.
for range 10_000 {
_, err := countFile.Write(contents)
assert.NilError(b, err)
}
err = countFile.Close()
assert.NilError(b, err)
b.ResetTimer()
for range b.N {
_, err = countLines(countFileName)
assert.NilError(b, err)
}
}
moor-2.10.3/internal/reader/zopen.go 0000664 0000000 0000000 00000007231 15135744745 0017333 0 ustar 00root root 0000000 0000000 package reader
import (
"bytes"
"compress/bzip2"
"compress/gzip"
"fmt"
"io"
"os"
"strings"
"github.com/klauspost/compress/zstd"
log "github.com/sirupsen/logrus"
"github.com/ulikunitz/xz"
)
var gzipMagic = []byte{0x1f, 0x8b}
var bzip2Magic = []byte{0x42, 0x5a, 0x68}
var zstdMagic = []byte{0x28, 0xb5, 0x2f, 0xfd}
var xzMagic = []byte{0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00}
// The second return value is the file name with any compression extension removed.
func ZOpen(filename string) (io.ReadCloser, string, error) {
file, err := os.Open(filename)
if err != nil {
return nil, "", err
}
// Read the first 6 bytes to determine the compression type
firstBytes := make([]byte, 6)
_, err = file.Read(firstBytes)
if err != nil {
if err == io.EOF {
// File was empty
return file, filename, nil
}
return nil, "", fmt.Errorf("failed to read file: %w", err)
}
// Reset file reader to start of file
_, err = file.Seek(0, 0)
if err != nil {
return nil, "", fmt.Errorf("failed to seek to start of file: %w", err)
}
switch {
case bytes.HasPrefix(firstBytes, gzipMagic):
log.Debugf("File is gzip compressed: %v", filename)
reader, err := gzip.NewReader(file)
if err != nil {
return nil, "", err
}
newName := strings.TrimSuffix(filename, ".gz")
// Ref: https://github.com/walles/moor/issues/194
if strings.HasSuffix(newName, ".tgz") {
newName = strings.TrimSuffix(newName, ".tgz") + ".tar"
}
return reader, newName, err
case bytes.HasPrefix(firstBytes, bzip2Magic):
log.Debugf("File is bzip2 compressed: %v", filename)
return struct {
io.Reader
io.Closer
}{bzip2.NewReader(file), file}, strings.TrimSuffix(filename, ".bz2"), nil
case bytes.HasPrefix(firstBytes, zstdMagic):
log.Debugf("File is zstd compressed: %v", filename)
decoder, err := zstd.NewReader(file)
if err != nil {
return nil, "", err
}
newName := strings.TrimSuffix(filename, ".zst")
newName = strings.TrimSuffix(newName, ".zstd")
return decoder.IOReadCloser(), newName, nil
case bytes.HasPrefix(firstBytes, xzMagic):
log.Debugf("File is xz compressed: %v", filename)
xzReader, err := xz.NewReader(file)
if err != nil {
return nil, "", err
}
return struct {
io.Reader
io.Closer
}{xzReader, file}, strings.TrimSuffix(filename, ".xz"), nil
}
log.Debugf("File is assumed to be uncompressed: %v", filename)
return file, filename, nil
}
// ZReader returns a reader that decompresses the input stream. Any input stream
// compression will be automatically detected. Uncompressed streams will be
// returned as-is.
//
// Ref: https://github.com/walles/moor/issues/261
func ZReader(input io.Reader) (io.Reader, error) {
// Read the first 6 bytes to determine the compression type
firstBytes := make([]byte, 6)
count, err := input.Read(firstBytes)
if err != nil {
if err == io.EOF {
// Stream was empty
return input, nil
}
return nil, fmt.Errorf("failed to read stream: %w", err)
}
firstBytes = firstBytes[:count]
// Reset input reader to start of stream
input = io.MultiReader(bytes.NewReader(firstBytes), input)
switch {
case bytes.HasPrefix(firstBytes, gzipMagic):
log.Info("Input stream is gzip compressed")
return gzip.NewReader(input)
case bytes.HasPrefix(firstBytes, zstdMagic):
log.Info("Input stream is zstd compressed")
return zstd.NewReader(input)
case bytes.HasPrefix(firstBytes, bzip2Magic):
log.Info("Input stream is bzip2 compressed")
return bzip2.NewReader(input), nil
case bytes.HasPrefix(firstBytes, xzMagic):
log.Info("Input stream is xz compressed")
return xz.NewReader(input)
default:
// No magic numbers matched
log.Info("Input stream is assumed to be uncompressed")
return input, nil
}
}
moor-2.10.3/internal/reader/zopen_test.go 0000664 0000000 0000000 00000001263 15135744745 0020371 0 ustar 00root root 0000000 0000000 package reader
import (
"bytes"
"io"
"testing"
"gotest.tools/v3/assert"
)
// Test that ZReader works with an empty stream
func TestZReaderEmpty(t *testing.T) {
bytesReader := bytes.NewReader([]byte{})
zReader, err := ZReader(bytesReader)
assert.NilError(t, err)
all, err := io.ReadAll(zReader)
assert.NilError(t, err)
assert.Equal(t, 0, len(all))
}
// Test that ZReader works with a one-byte stream
func TestZReaderOneByte(t *testing.T) {
bytesReader := bytes.NewReader([]byte{42})
zReader, err := ZReader(bytesReader)
assert.NilError(t, err)
all, err := io.ReadAll(zReader)
assert.NilError(t, err)
assert.Equal(t, 1, len(all))
assert.Equal(t, byte(42), all[0])
}
moor-2.10.3/internal/screenLines.go 0000664 0000000 0000000 00000033472 15135744745 0017216 0 ustar 00root root 0000000 0000000 package internal
import (
"fmt"
"github.com/davecgh/go-spew/spew"
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/reader"
"github.com/walles/moor/v2/internal/textstyles"
"github.com/walles/moor/v2/twin"
)
type renderedLine struct {
// Certain lines are available for viewing. This index is the (zero based)
// position of this line among those.
inputLineIndex linemetadata.Index
// If an input line has been wrapped into two, the part on the second line
// will have a wrapIndex of 1.
wrapIndex int
containsSearchHit bool
cells textstyles.CellWithMetadataSlice
// Used for rendering clear-to-end-of-line control sequences:
// https://en.wikipedia.org/wiki/ANSI_escape_code#EL
//
// Ref: https://github.com/walles/moor/issues/106
trailer twin.Style
}
type renderedScreen struct {
lines []renderedLine
inputLines []reader.NumberedLine
numberPrefixWidth int // Including padding. 0 means no line numbers.
filenameText string
statusText string
}
// Refresh the whole pager display, both contents lines and the status line at
// the bottom
func (p *Pager) redraw(spinner string) {
log.Trace("redraw called")
p.screen.Clear()
p.longestLineLength = 0
lastUpdatedScreenLineNumber := -1
renderedScreen := p.renderLines()
for screenLineNumber, row := range renderedScreen.lines {
lastUpdatedScreenLineNumber = screenLineNumber
column := 0
for _, cell := range row.cells {
column += p.screen.SetCell(column, lastUpdatedScreenLineNumber, cell.ToStyledRune())
}
}
// Status line code follows
eofSpinner := spinner
if eofSpinner == "" {
// This happens when we're done
eofSpinner = "---"
}
spinnerLine := textstyles.StyledRunesFromString(statusbarStyle, eofSpinner, nil, 0).StyledRunes
column := 0
for _, cell := range spinnerLine {
column += p.screen.SetCell(column, lastUpdatedScreenLineNumber+1, cell.ToStyledRune())
}
p.mode.drawFooter(renderedScreen.filenameText, renderedScreen.statusText, spinner)
p.screen.Show()
}
// Render all lines that should go on the screen.
//
// Returns both the lines and a suitable status text.
//
// The returned lines are display ready, meaning that they come with horizontal
// scroll markers and line numbers as necessary.
//
// The maximum number of lines returned by this method is limited by the screen
// height. If the status line is visible, you'll get at most one less than the
// screen height from this method.
func (p *Pager) renderLines() renderedScreen {
rendered := p.internalRenderLines(true)
hasSearchHitLines := false
hasNonSearchHitLines := false
for _, line := range rendered.lines {
if line.containsSearchHit {
hasSearchHitLines = true
} else {
hasNonSearchHitLines = true
}
}
if hasSearchHitLines && !hasNonSearchHitLines {
// All lines have search hits, don't highlight any lines
// Ref: https://github.com/walles/moor/issues/335
rendered = p.internalRenderLines(false)
}
return rendered
}
func (p *Pager) internalRenderLines(highlightSearchHitLines bool) renderedScreen {
var lineIndexToShow linemetadata.Index
if p.lineIndex() != nil {
lineIndexToShow = *p.lineIndex()
}
inputLines := p.Reader().GetLines(lineIndexToShow, p.visibleHeight())
if len(inputLines.Lines) == 0 {
// Empty input, empty output
return renderedScreen{filenameText: inputLines.FilenameText, statusText: inputLines.StatusText}
}
lastVisibleLineNumber := inputLines.Lines[len(inputLines.Lines)-1].Number
numberPrefixLength := p.getLineNumberPrefixLength(lastVisibleLineNumber)
allLines := make([]renderedLine, 0)
for _, line := range inputLines.Lines {
rendering := p.renderLine(line, numberPrefixLength, highlightSearchHitLines)
var onScreenLength int
for i := range rendering {
trimmedLen := len(rendering[i].cells.WithoutSpaceRight())
if trimmedLen > onScreenLength {
onScreenLength = trimmedLen
}
}
// We're trying to find the max length of readable characters to limit
// the scrolling to right, so we don't go over into the vast emptiness for no reason.
//
// The -1 fixed an issue that seemed like an off-by-one where sometimes, when first
// scrolling completely to the right, the first left scroll did not show the text again.
displayLength := p.leftColumnZeroBased + onScreenLength - 1
if displayLength >= p.longestLineLength {
p.longestLineLength = displayLength
}
allLines = append(allLines, rendering...)
}
// Find which index in allLines the user wants to see at the top of the
// screen
firstVisibleIndex := -1 // Not found
for index, line := range allLines {
if p.lineIndex() == nil {
// Expected zero lines but got some anyway, grab the first one!
firstVisibleIndex = index
break
}
if line.inputLineIndex == *p.lineIndex() && line.wrapIndex == p.deltaScreenLines() {
firstVisibleIndex = index
break
}
}
if firstVisibleIndex == -1 {
allLinesDescription := fmt.Sprintf("size %d", len(allLines))
if len(allLines) > 0 {
allLinesDescription += fmt.Sprintf(", indexed %d-%d:\n",
allLines[0].inputLineIndex, allLines[len(allLines)-1].inputLineIndex)
}
panic(fmt.Errorf("scrollPosition index=%d not found in allLines %s:\n%s",
lineIndexToShow,
allLinesDescription,
spew.Sdump(p.scrollPosition),
))
}
// Drop the lines that should go above the screen
allLines = allLines[firstVisibleIndex:]
// Drop the lines that would have gone below the screen
wantedLineCount := p.visibleHeight()
if len(allLines) > wantedLineCount {
allLines = allLines[0:wantedLineCount]
}
// Fill in the line trailers
screenWidth, _ := p.screen.Size()
for i := range allLines {
line := &allLines[i]
if line.trailer == twin.StyleDefault {
continue
}
for len(line.cells) < screenWidth {
line.cells = append(line.cells, textstyles.CellWithMetadata{Rune: ' ', Style: line.trailer})
}
}
return renderedScreen{
lines: allLines,
filenameText: inputLines.FilenameText,
statusText: inputLines.StatusText,
inputLines: inputLines.Lines,
numberPrefixWidth: numberPrefixLength,
}
}
// Render one input line into one or more screen lines.
//
// The returned line is display ready, meaning that it comes with horizontal
// scroll markers and line number as necessary.
//
// lineNumber and numberPrefixLength are required for knowing how much to
// indent, and to (optionally) render the line number.
func (p *Pager) renderLine(line reader.NumberedLine, numberPrefixLength int, highlightSearchHitLines bool) []renderedLine {
width, _ := p.screen.Size()
var wrapped []textstyles.StyledRunesWithTrailer
var highlighted textstyles.StyledRunesWithTrailer
if p.WrapLongLines {
highlighted = line.HighlightedTokens(plainTextStyle, searchHitStyle, p.search, 0)
wrapped = wrapLine(width-numberPrefixLength, highlighted.StyledRunes)
} else {
// Request only screen width tokens plus whatever is needed on the left
// due to horizontal scrolling. Also, get one extra to the right so we
// can know whether to show overflow markers.
//
// This is a huge performance gain when dealing with files with
// extremeny long lines: https://github.com/walles/moor/issues/358
highlighted = line.HighlightedTokens(plainTextStyle, searchHitStyle, p.search, width+p.leftColumnZeroBased+1)
// All on one line
wrapped = []textstyles.StyledRunesWithTrailer{{
StyledRunes: highlighted.StyledRunes,
Trailer: highlighted.Trailer,
ContainsSearchHit: highlighted.ContainsSearchHit,
}}
}
if highlightSearchHitLines && searchHitLineBackground != nil {
// Highlight any sub lines with search hits
for i := range wrapped {
line := &wrapped[i] // We need a pointer to modify in place, otherwise setting the trailer won't have any effect
if line.ContainsSearchHit {
// Highlight this line!
for i := range line.StyledRunes {
line.StyledRunes[i].Style = line.StyledRunes[i].Style.WithBackground(*searchHitLineBackground)
}
line.Trailer = line.Trailer.WithBackground(*searchHitLineBackground)
}
}
}
rendered := make([]renderedLine, 0)
for wrapIndex, subLine := range wrapped {
lineNumber := line.Number
visibleLineNumber := &lineNumber
if wrapIndex > 0 {
visibleLineNumber = nil
}
decorated := p.decorateLine(visibleLineNumber, numberPrefixLength, subLine.StyledRunes)
rendered = append(rendered, renderedLine{
inputLineIndex: line.Index,
wrapIndex: wrapIndex,
cells: decorated,
containsSearchHit: subLine.ContainsSearchHit,
trailer: subLine.Trailer,
})
}
lastRenderedLine := &rendered[len(rendered)-1]
lastLineHasSearchHighlight := highlightSearchHitLines && lastRenderedLine.containsSearchHit
if highlighted.Trailer != twin.StyleDefault && !lastLineHasSearchHighlight {
// In the presence of wrapping, add the trailer to the last of the wrap
// lines only. This matches what both iTerm and the macOS Terminal does.
lastRenderedLine.trailer = highlighted.Trailer
}
return rendered
}
// Take a rendered line and decorate as needed:
// - Line number, or leading whitespace for wrapped lines
// - Scroll left indicator
// - Scroll right indicator
func (p *Pager) decorateLine(lineNumberToShow *linemetadata.Number, numberPrefixLength int, contents []textstyles.CellWithMetadata) []textstyles.CellWithMetadata {
width, _ := p.screen.Size()
newLine := make([]textstyles.CellWithMetadata, 0, width)
newLine = append(newLine, createLinePrefix(lineNumberToShow, numberPrefixLength)...)
// Find the first and last fully visible runes.
var firstVisibleRuneIndex *int
lastVisibleRuneIndex := -1
screenColumn := numberPrefixLength // Zero based
lastVisibleScreenColumn := p.leftColumnZeroBased + width - 1
cutOffRuneToTheLeft := false
cutOffRuneToTheRight := false
canScrollRight := false
for i, char := range contents {
if firstVisibleRuneIndex == nil && screenColumn >= p.leftColumnZeroBased {
// Found the first fully visible rune. We need to point to a copy of
// our loop variable, not the loop variable itself. Just pointing to
// i, will make firstVisibleRuneIndex point to a new value for every
// iteration of the loop.
copyOfI := i
firstVisibleRuneIndex = ©OfI
if i > 0 && screenColumn > p.leftColumnZeroBased && contents[i-1].Width() > 1 {
// We had to cut a rune in half at the start
cutOffRuneToTheLeft = true
}
}
screenReached := firstVisibleRuneIndex != nil
currentCharRightEdge := screenColumn + char.Width() - 1
beforeRightEdge := currentCharRightEdge <= lastVisibleScreenColumn
if screenReached {
if beforeRightEdge {
// This rune is fully visible
lastVisibleRuneIndex = i
} else {
// We're just outside the screen on the right
canScrollRight = true
currentCharLeftEdge := screenColumn
if currentCharLeftEdge <= lastVisibleScreenColumn {
// We have to cut this rune in half
cutOffRuneToTheRight = true
}
// Search done, we're off the right edge
break
}
}
screenColumn += char.Width()
}
// Prepend a space if we had to cut a rune in half at the start
if cutOffRuneToTheLeft {
newLine = append([]textstyles.CellWithMetadata{{Rune: ' ', Style: p.ScrollLeftHint.Style}}, newLine...)
}
// Add the visible runes
if firstVisibleRuneIndex != nil {
newLine = append(newLine, contents[*firstVisibleRuneIndex:lastVisibleRuneIndex+1]...)
}
// Append a space if we had to cut a rune in half at the end
if cutOffRuneToTheRight {
newLine = append(newLine, textstyles.CellWithMetadata{Rune: ' ', Style: p.ScrollRightHint.Style})
}
// Add scroll left indicator
canScrollLeft := p.leftColumnZeroBased > 0
if canScrollLeft && len(contents) > 0 {
if len(newLine) == 0 {
// Make room for the scroll left indicator
newLine = make([]textstyles.CellWithMetadata, 1)
}
if newLine[0].Width() > 1 {
// Replace the first rune with two spaces so we can replace the
// leftmost cell with a scroll left indicator. First, convert to one
// space...
newLine[0] = textstyles.CellWithMetadata{Rune: ' ', Style: p.ScrollLeftHint.Style}
// ...then prepend another space:
newLine = append([]textstyles.CellWithMetadata{{Rune: ' ', Style: p.ScrollLeftHint.Style}}, newLine...)
// Prepending ref: https://stackoverflow.com/a/53737602/473672
}
// Set can-scroll-left marker
newLine[0] = p.ScrollLeftHint
}
// Add scroll right indicator
if canScrollRight {
if newLine[len(newLine)-1].Width() > 1 {
// Replace the last rune with two spaces so we can replace the
// rightmost cell with a scroll right indicator. First, convert to one
// space...
newLine[len(newLine)-1] = textstyles.CellWithMetadata{Rune: ' ', Style: p.ScrollRightHint.Style}
// ...then append another space:
newLine = append(newLine, textstyles.CellWithMetadata{Rune: ' ', Style: p.ScrollRightHint.Style})
}
newLine[len(newLine)-1] = p.ScrollRightHint
}
return newLine
}
// Generate a line number prefix of the given length.
//
// Can be empty or all-whitespace depending on parameters.
func createLinePrefix(lineNumber *linemetadata.Number, numberPrefixLength int) []textstyles.CellWithMetadata {
if numberPrefixLength == 0 {
return []textstyles.CellWithMetadata{}
}
lineNumberPrefix := make([]textstyles.CellWithMetadata, 0, numberPrefixLength)
if lineNumber == nil {
for len(lineNumberPrefix) < numberPrefixLength {
lineNumberPrefix = append(lineNumberPrefix, textstyles.CellWithMetadata{Rune: ' '})
}
return lineNumberPrefix
}
lineNumberString := fmt.Sprintf("%*s ", numberPrefixLength-1, lineNumber.Format())
if len(lineNumberString) > numberPrefixLength {
panic(fmt.Errorf(
"lineNumberString <%s> longer than numberPrefixLength %d",
lineNumberString, numberPrefixLength))
}
for column, digit := range lineNumberString {
if column >= numberPrefixLength {
break
}
lineNumberPrefix = append(lineNumberPrefix, textstyles.CellWithMetadata{Rune: digit, Style: lineNumbersStyle})
}
return lineNumberPrefix
}
moor-2.10.3/internal/screenLines_test.go 0000664 0000000 0000000 00000034450 15135744745 0020252 0 ustar 00root root 0000000 0000000 package internal
import (
"strconv"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/reader"
"github.com/walles/moor/v2/internal/search"
"github.com/walles/moor/v2/internal/textstyles"
"github.com/walles/moor/v2/twin"
"gotest.tools/v3/assert"
log "github.com/sirupsen/logrus"
)
// NOTE: You can find related tests in pager_test.go.
// Converts a cell row to a plain string and removes trailing whitespace.
func renderedToString(row []textstyles.CellWithMetadata) string {
rowString := ""
for _, cell := range row {
rowString += string(cell.Rune)
}
return strings.TrimRight(rowString, " ")
}
func testHorizontalCropping(t *testing.T, contents string, firstVisibleColumn int, lastVisibleColumn int, expected string) {
log.SetLevel(log.WarnLevel) // Stop info logs from polluting benchmark output
reader := reader.NewFromTextForTesting("testHorizontalCropping", contents)
assert.NilError(t, reader.Wait())
pager := NewPager(reader)
pager.ShowLineNumbers = false
pager.showLineNumbers = false
pager.screen = twin.NewFakeScreen(1+lastVisibleColumn-firstVisibleColumn, 99)
pager.leftColumnZeroBased = firstVisibleColumn
pager.scrollPosition = newScrollPosition("testHorizontalCropping")
numberedLine := reader.GetLine(linemetadata.IndexFromZeroBased(0))
assert.Assert(t, numberedLine != nil)
screenLine := pager.renderLine(*numberedLine, pager.getLineNumberPrefixLength(numberedLine.Number), true)
assert.Equal(t, renderedToString(screenLine[0].cells), expected)
}
func TestCreateScreenLine(t *testing.T) {
testHorizontalCropping(t, "abc", 0, 10, "abc")
}
func TestCreateScreenLineCanScrollLeft(t *testing.T) {
testHorizontalCropping(t, "abc", 1, 10, "")
}
func TestCreateScreenLineCanAlmostScrollRight(t *testing.T) {
testHorizontalCropping(t, "abc", 0, 2, "abc")
}
func TestCreateScreenLineCanScrollBoth(t *testing.T) {
testHorizontalCropping(t, "abcde", 1, 3, "")
}
func TestCreateScreenLineCanAlmostScrollBoth(t *testing.T) {
testHorizontalCropping(t, "abcd", 1, 3, "")
testHorizontalCropping(t, "上åˆä¸‹", 0, 3, "上 >")
testHorizontalCropping(t, "上åˆä¸‹", 0, 2, "上>")
testHorizontalCropping(t, "上åˆä¸‹", 0, 1, " >")
}
func TestEmpty(t *testing.T) {
pager := Pager{
screen: twin.NewFakeScreen(99, 10),
// No lines available
readers: []*reader.ReaderImpl{reader.NewFromTextForTesting("test", "")},
scrollPosition: newScrollPosition("TestEmpty"),
}
pager.filteringReader = FilteringReader{
BackingReader: pager.readers[pager.currentReader],
Filter: &pager.filter,
}
rendered := pager.renderLines()
assert.Equal(t, len(rendered.lines), 0)
assert.Equal(t, "test", rendered.filenameText)
assert.Equal(t, ": ", rendered.statusText)
assert.Assert(t, pager.lineIndex() == nil)
}
// Repro case for a search bug discovered in v1.9.8.
func TestSearchHighlight(t *testing.T) {
numberedLine := reader.NewFromTextForTesting("TestSearchHighlight", "x\"\"x").GetLine(linemetadata.Index{})
pager := Pager{
screen: twin.NewFakeScreen(100, 10),
search: search.For("\""),
}
rendered := pager.renderLine(*numberedLine, pager.getLineNumberPrefixLength(numberedLine.Number), true)
assert.DeepEqual(t, []renderedLine{
{
inputLineIndex: linemetadata.Index{},
wrapIndex: 0,
containsSearchHit: true,
cells: []textstyles.CellWithMetadata{
{Rune: 'x', Style: twin.StyleDefault},
{Rune: '"', Style: twin.StyleDefault.WithAttr(twin.AttrReverse), IsSearchHit: true, StartsSearchHit: true},
{Rune: '"', Style: twin.StyleDefault.WithAttr(twin.AttrReverse), IsSearchHit: true, StartsSearchHit: false},
{Rune: 'x', Style: twin.StyleDefault},
},
},
}, rendered,
cmp.AllowUnexported(twin.Style{}),
cmp.AllowUnexported(renderedLine{}),
cmp.AllowUnexported(linemetadata.Number{}),
cmp.AllowUnexported(linemetadata.Index{}),
)
}
func TestOverflowDown(t *testing.T) {
pager := Pager{
screen: twin.NewFakeScreen(
10, // Longer than the raw line, we're testing vertical overflow, not horizontal
2, // Single line of contents + one status line
),
// Single line of input
readers: []*reader.ReaderImpl{reader.NewFromTextForTesting("test", "hej")},
// This value can be anything and should be clipped, that's what we're testing
scrollPosition: *scrollPositionFromIndex("TestOverflowDown", linemetadata.IndexFromOneBased(42)),
}
pager.filteringReader = FilteringReader{
BackingReader: pager.readers[pager.currentReader],
Filter: &pager.filter,
}
rendered := pager.renderLines()
assert.Equal(t, len(rendered.lines), 1)
assert.Equal(t, "hej", renderedToString(rendered.lines[0].cells))
assert.Equal(t, "test", rendered.filenameText)
assert.Equal(t, ": 1 line 100%", rendered.statusText)
assert.Assert(t, pager.lineIndex().IsZero())
assert.Equal(t, pager.deltaScreenLines(), 0)
}
func TestOverflowUp(t *testing.T) {
pager := Pager{
screen: twin.NewFakeScreen(
10, // Longer than the raw line, we're testing vertical overflow, not horizontal
2, // Single line of contents + one status line
),
// Single line of input
readers: []*reader.ReaderImpl{reader.NewFromTextForTesting("test", "hej")},
// NOTE: scrollPosition intentionally not initialized
}
pager.filteringReader = FilteringReader{
BackingReader: pager.readers[pager.currentReader],
Filter: &pager.filter,
}
rendered := pager.renderLines()
assert.Equal(t, len(rendered.lines), 1)
assert.Equal(t, "hej", renderedToString(rendered.lines[0].cells))
assert.Equal(t, "test", rendered.filenameText)
assert.Equal(t, ": 1 line 100%", rendered.statusText)
assert.Assert(t, pager.lineIndex().IsZero())
assert.Equal(t, pager.deltaScreenLines(), 0)
}
func TestWrapping(t *testing.T) {
reader := reader.NewFromTextForTesting("",
"first line\nline two will be wrapped\nhere's the last line")
pager := NewPager(reader)
pager.screen = twin.NewFakeScreen(40, 40)
pager.WrapLongLines = true
pager.ShowLineNumbers = false
pager.showLineNumbers = false
assert.NilError(t, reader.Wait())
// This is what we're testing really
pager.scrollToEnd()
// Higher than needed, we'll just be validating the necessary lines at the
// top.
screen := twin.NewFakeScreen(10, 99)
// Exit immediately
pager.Quit()
// Get contents onto our fake screen
pager.StartPaging(screen, nil, nil)
pager.redraw("")
actual := strings.Join([]string{
rowToString(screen.GetRow(0)),
rowToString(screen.GetRow(1)),
rowToString(screen.GetRow(2)),
rowToString(screen.GetRow(3)),
rowToString(screen.GetRow(4)),
rowToString(screen.GetRow(5)),
rowToString(screen.GetRow(6)),
rowToString(screen.GetRow(7)),
}, "\n")
assert.Equal(t, actual, strings.Join([]string{
"first line",
"line two",
"will be",
"wrapped",
"here's the",
"last line",
"---",
"",
}, "\n"))
}
// Repro for https://github.com/walles/moor/issues/153
func TestOneLineTerminal(t *testing.T) {
pager := Pager{
// Single line terminal window, this is what we're testing
screen: twin.NewFakeScreen(20, 1),
readers: []*reader.ReaderImpl{reader.NewFromTextForTesting("test", "hej")},
ShowStatusBar: true,
}
pager.filteringReader = FilteringReader{
BackingReader: pager.readers[pager.currentReader],
Filter: &pager.filter,
}
rendered := pager.renderLines()
assert.Equal(t, len(rendered.lines), 0)
}
// What happens if we are scrolled to the bottom of a 1000 lines file, and then
// add a filter matching only the first line?
//
// What should happen is that we should go as far down as possible.
func TestShortenedInput(t *testing.T) {
pager := Pager{
screen: twin.NewFakeScreen(20, 10),
// 1000 lines of input, we will scroll to the bottom
readers: []*reader.ReaderImpl{reader.NewFromTextForTesting("test", "first\n"+strings.Repeat("line\n", 1000))},
scrollPosition: newScrollPosition("TestShortenedInput"),
}
// Hide the status bar for this test
pager.mode = PagerModeViewing{&pager}
pager.ShowStatusBar = false
pager.filteringReader = FilteringReader{
BackingReader: pager.readers[pager.currentReader],
Filter: &pager.filter,
}
pager.scrollToEnd()
assert.Equal(t, pager.lineIndex().Index(), 991, "This should have been the effect of calling scrollToEnd()")
pager.mode = NewPagerModeFilter(&pager)
pager.filter = search.For("first") // Match only the first line
rendered := pager.renderLines()
assert.Equal(t, len(rendered.lines), 1, "Should have rendered one line")
assert.Equal(t, "first", renderedToString(rendered.lines[0].cells))
assert.Equal(t, pager.lineIndex().Index(), 0, "Should have scrolled to the first line")
}
// - Start with a 1000 lines file
// - Scroll to the bottom
// - Add a filter matching the first 100 lines
// - Render
// - Verify that the 10 last matching lines were rendered
func TestShortenedInputManyLines(t *testing.T) {
lines := []string{"first"}
for i := range 999 {
if i < 100 {
lines = append(lines, "match "+strconv.Itoa(i))
} else {
lines = append(lines, "other "+strconv.Itoa(i))
}
}
pager := Pager{
screen: twin.NewFakeScreen(20, 10),
readers: []*reader.ReaderImpl{reader.NewFromTextForTesting("test", strings.Join(lines, "\n"))},
scrollPosition: newScrollPosition("TestShortenedInputManyLines"),
}
pager.filteringReader = FilteringReader{
BackingReader: pager.readers[pager.currentReader],
Filter: &pager.filter,
}
pager.scrollToEnd()
assert.Equal(t, pager.lineIndex().Index(), 991, "Should be at the last line before filtering")
pager.mode = NewPagerModeFilter(&pager)
pager.filter = search.For(`^match`)
rendered := pager.renderLines()
assert.Equal(t, len(rendered.lines), 9, "Should have rendered 9 lines (10 minus one status bar)")
expectedLines := []string{}
for i := 91; i < 100; i++ {
expectedLines = append(expectedLines, "match "+strconv.Itoa(i))
}
for i, row := range rendered.lines {
assert.Equal(t, renderedToString(row.cells), expectedLines[i], "Line %d mismatch", i)
}
assert.Equal(t, pager.lineIndex().Index(), 91, "The last lines should now be visible")
assert.Equal(t, "match 99", renderedToString(rendered.lines[len(rendered.lines)-1].cells))
}
// Text lines are fewer than the number of screen lines. All lines have search
// hits.
//
// Expected: No search hit line highlighting, since highlighting all lines is
// sort of like highlighting no lines but with an uglier background.
func TestRenderLines_FewLinesAllWithSearchHits(t *testing.T) {
testRenderLinesWithSearchHits(t,
"1xxx",
[]twin.Color{twin.ColorDefault},
"TestRenderLines_FewLinesAllWithSearchHits",
)
}
// Text lines are more than the number of screen lines. All lines on screen have
// search hits.
//
// Expected: No search hit line highlighting, since highlighting all lines is
// sort of like highlighting no lines but with an uglier background.
func TestRenderLines_ManyLinesAllWithSearchHits(t *testing.T) {
testRenderLinesWithSearchHits(t,
"1xxx\n2xxx\n3xxx\n4xxx",
[]twin.Color{twin.ColorDefault, twin.ColorDefault, twin.ColorDefault},
"TestRenderLines_ManyLinesAllWithSearchHits",
)
}
// Some lines with search hits and one line without. The ones with search hits
// should be line highlighted.
func TestRenderLines_OneLineWithoutSearchHit(t *testing.T) {
testRenderLinesWithSearchHits(t,
"1xxx\n2miss\n3xxx\n4xxx",
[]twin.Color{red, twin.ColorDefault, red},
"TestRenderLines_OneLineWithoutSearchHit",
)
}
var red = twin.NewColor24Bit(0xff, 0x00, 0x00)
// Helper for search hit line highlight tests.
//
// Search pattern is always "xxx", because "x" marks the spot. The
// expectedBackgrounds are the expected background colors for the first (always
// unmatched) letter on each line.
//
// The screen being rendered to is always 10 columns wide and 3 rows high.
func testRenderLinesWithSearchHits(t *testing.T, input string, expectedBackgrounds []twin.Color, scrollPositionName string) {
r := reader.NewFromTextForTesting("test", input)
pager := Pager{
screen: twin.NewFakeScreen(10, 3),
readers: []*reader.ReaderImpl{r},
scrollPosition: newScrollPosition(scrollPositionName),
}
pager.filteringReader = FilteringReader{
BackingReader: pager.readers[pager.currentReader],
Filter: &pager.filter,
}
pager.search = search.For("xxx")
pager.ShowStatusBar = false
pager.mode = PagerModeViewing{&pager}
pager.showLineNumbers = false
assert.NilError(t, r.Wait())
searchHitLineBackground = &red
rendered := pager.renderLines()
assert.Equal(t, len(rendered.lines), len(expectedBackgrounds))
for i := range expectedBackgrounds {
assert.Equal(t, rendered.lines[i].cells[0].Style.Background(), expectedBackgrounds[i])
}
}
func BenchmarkRenderLines(b *testing.B) {
input := reader.NewFromTextForTesting(
"BenchmarkRenderLine()",
strings.Repeat("This is a line with text and some more text to make it long enough.\n", 100))
pager := NewPager(input)
pager.screen = twin.NewFakeScreen(80, 25)
assert.NilError(b, input.Wait())
pager.renderLines() // Warm up
b.ResetTimer()
for i := 0; i < b.N; i++ {
pager.renderLines()
}
}
// Inspired by https://github.com/walles/moor/issues/358
func BenchmarkRenderHugeLine(b *testing.B) {
const megabytes = 5
builder := strings.Builder{}
for builder.Len() < megabytes*1024*1024 {
builder.WriteString("Romani ite domum. ")
}
b.SetBytes(int64(builder.Len()))
input := reader.NewFromTextForTesting(
"BenchmarkRenderHugeLine()",
builder.String())
pager := NewPager(input)
pager.screen = twin.NewFakeScreen(80, 25)
assert.NilError(b, input.Wait())
pager.renderLines() // Warm up
b.ResetTimer()
for i := 0; i < b.N; i++ {
pager.renderLines()
}
}
moor-2.10.3/internal/screenStyle.go 0000664 0000000 0000000 00000002350 15135744745 0017233 0 ustar 00root root 0000000 0000000 package internal
import (
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/styles"
"github.com/walles/moor/v2/twin"
)
const defaultDarkTheme = "native"
// I decided on a light theme by doing this, looking for black text on white,
// and no background colors on strings (they look like error markers):
//
// rg -i 'Background.*bg:#ffffff' | rg -v '[^:]#[^0]' | sort | cut -d: -f1 | xargs rg --files-without-match '"LiteralString".*bg:' | xargs wc -l | sort -r
//
// Then I picked tango because it has a bright background, a dark foreground and
// it looks OK on a white terminal with an unmodified color palette.
const defaultLightTheme = "tango"
// Checks the terminal background color and returns either a dark or light theme
func GetStyleForScreen(screen twin.Screen) chroma.Style {
bgColor := screen.TerminalBackground()
if bgColor == nil {
// Fall back to dark theme if we can't detect the background color
return *styles.Get(defaultDarkTheme)
}
distanceToBlack := bgColor.Distance(twin.NewColor24Bit(0, 0, 0))
distanceToWhite := bgColor.Distance(twin.NewColor24Bit(255, 255, 255))
if distanceToBlack < distanceToWhite {
return *styles.Get(defaultDarkTheme)
} else {
return *styles.Get(defaultLightTheme)
}
}
moor-2.10.3/internal/scrollPosition.go 0000664 0000000 0000000 00000031401 15135744745 0017755 0 ustar 00root root 0000000 0000000 package internal
import (
"fmt"
"github.com/walles/moor/v2/internal/linemetadata"
)
// Please create using newScrollPosition(name)
type scrollPosition struct {
internalDontTouch scrollPositionInternal
}
func newScrollPosition(name string) scrollPosition {
if len(name) == 0 {
panic("Non-empty name required")
}
return scrollPosition{
internalDontTouch: scrollPositionInternal{
name: name,
},
}
}
type scrollPositionInternal struct {
// Index into the array of visible input lines, or nil if nothing has been
// read yet or there are no lines.
lineIndex *linemetadata.Index
// Scroll this many screen lines before rendering. Can be negative.
deltaScreenLines int
name string
canonicalizing bool
canonical scrollPositionCanonical
}
// If any of these change, we have to recompute the scrollPositionInternal values
type scrollPositionCanonical struct {
width int // From pager
height int // From pager
showLineNumbers bool // From pager
showStatusBar bool // From pager
wrapLongLines bool // From pager
pagerLineCount int // From pager.Reader().GetLineCount()
lineIndex *linemetadata.Index // From scrollPositionInternal
deltaScreenLines int // From scrollPositionInternal
}
func canonicalFromPager(pager *Pager) scrollPositionCanonical {
width, _ := pager.screen.Size()
height := pager.visibleHeight()
return scrollPositionCanonical{
width: width,
height: height,
showLineNumbers: pager.showLineNumbers,
showStatusBar: pager.ShowStatusBar,
wrapLongLines: pager.WrapLongLines,
pagerLineCount: pager.Reader().GetLineCount(),
lineIndex: pager.scrollPosition.internalDontTouch.lineIndex,
deltaScreenLines: pager.scrollPosition.internalDontTouch.deltaScreenLines,
}
}
// Create a new position, scrolled towards the beginning of the file
func (sp scrollPosition) PreviousLine(scrollDistance int) scrollPosition {
return scrollPosition{
internalDontTouch: scrollPositionInternal{
name: sp.internalDontTouch.name,
lineIndex: sp.internalDontTouch.lineIndex,
deltaScreenLines: sp.internalDontTouch.deltaScreenLines - scrollDistance,
},
}
}
// Create a new position, scrolled towards the end of the file
func (sp scrollPosition) NextLine(scrollDistance int) scrollPosition {
return scrollPosition{
internalDontTouch: scrollPositionInternal{
name: sp.internalDontTouch.name,
lineIndex: sp.internalDontTouch.lineIndex,
deltaScreenLines: sp.internalDontTouch.deltaScreenLines + scrollDistance,
},
}
}
func (p *Pager) ScrollPositionsEqual(a, b scrollPosition) bool {
a.internalDontTouch.canonicalize(p)
b.internalDontTouch.canonicalize(p)
if a.internalDontTouch.deltaScreenLines != b.internalDontTouch.deltaScreenLines {
return false
}
if (a.internalDontTouch.lineIndex == nil) != (b.internalDontTouch.lineIndex == nil) {
// One line index is nil, the other is not
return false
}
if a.internalDontTouch.lineIndex != nil && b.internalDontTouch.lineIndex != nil {
// Both line indexes are non-nil...
if *a.internalDontTouch.lineIndex != *b.internalDontTouch.lineIndex {
// ... but they point to different lines.
return false
}
}
return true
}
// Create a new position, scrolled to the given line number
//
//revive:disable-next-line:unexported-return
func NewScrollPositionFromIndex(index linemetadata.Index, name string) scrollPosition {
return scrollPosition{
internalDontTouch: scrollPositionInternal{
name: name,
lineIndex: &index,
deltaScreenLines: 0,
},
}
}
// Move towards the top until deltaScreenLines is not negative any more
func (si *scrollPositionInternal) handleNegativeDeltaScreenLines(pager *Pager) {
for !si.lineIndex.IsZero() && si.deltaScreenLines < 0 {
// Render the previous line
previousLineIndex := si.lineIndex.NonWrappingAdd(-1)
previousLine := pager.Reader().GetLine(previousLineIndex)
previousSubLinesCount := 0
if previousLine != nil {
previousSubLines := pager.renderLine(*previousLine, si.getMaxNumberPrefixLength(pager), true)
previousSubLinesCount = len(previousSubLines)
}
// Adjust lineNumber and deltaScreenLines to move up into the previous
// screen line
si.lineIndex = &previousLineIndex
si.deltaScreenLines += previousSubLinesCount
}
if si.lineIndex.IsZero() && si.deltaScreenLines <= 0 {
// Can't go any higher
si.deltaScreenLines = 0
return
}
}
// Move towards the bottom until deltaScreenLines is within range of the
// rendering of the current line.
//
// This method will not do any screen-height based clipping, so it could be that
// the position is too far down to display after this returns.
func (si *scrollPositionInternal) handlePositiveDeltaScreenLines(pager *Pager) {
maxPrefixLength := si.getMaxNumberPrefixLength(pager)
for {
line := pager.Reader().GetLine(*si.lineIndex)
if line == nil {
// Out of bounds downwards, get the last line...
si.lineIndex = linemetadata.IndexFromLength(pager.Reader().GetLineCount())
line = pager.Reader().GetLine(*si.lineIndex)
if line == nil {
panic(fmt.Errorf("Last line is nil"))
}
subLines := pager.renderLine(*line, maxPrefixLength, true)
// ... and go to the bottom of that.
si.deltaScreenLines = len(subLines) - 1
return
}
subLines := pager.renderLine(*line, maxPrefixLength, true)
if si.deltaScreenLines < len(subLines) {
// Sublines are within bounds!
return
}
nextLineIndex := si.lineIndex.NonWrappingAdd(1)
si.lineIndex = &nextLineIndex
si.deltaScreenLines -= len(subLines)
}
}
// This method assumes si contains a canonical position
func (si *scrollPositionInternal) emptyBottomLinesCount(pager *Pager) int {
unclaimedViewportLines := pager.visibleHeight()
if unclaimedViewportLines == 0 {
// No lines at all => no lines are empty. Happens (at least) during
// testing.
return 0
}
if pager.Reader().GetLineCount() == 0 {
// No lines available, so all viewport lines are unclaimed
return unclaimedViewportLines
}
// Start counting where the current input line begins
unclaimedViewportLines += si.deltaScreenLines
lineIndex := *si.lineIndex
lastLineNumberWidth := si.getMaxNumberPrefixLength(pager)
for {
line := pager.Reader().GetLine(lineIndex)
if line == nil {
// No more lines!
break
}
subLines := pager.renderLine(*line, lastLineNumberWidth, true)
unclaimedViewportLines -= len(subLines)
if unclaimedViewportLines <= 0 {
return 0
}
// Move to the next line
lineIndex = lineIndex.NonWrappingAdd(1)
}
return unclaimedViewportLines
}
func (si *scrollPositionInternal) isCanonical(pager *Pager) bool {
if si.canonical.lineIndex == nil {
// Awaiting initial lines from the reader
return false
}
if si.canonical == canonicalFromPager(pager) {
return true
}
return false
}
// Only to be called from the scrollPosition getters!!
//
// Canonicalize the scroll position vs the given pager. A canonical position can
// just be displayed on screen, it has been clipped both towards the top and
// bottom of the screen, taking into account the screen height.
func (si *scrollPositionInternal) canonicalize(pager *Pager) {
if si.isCanonical(pager) {
return
}
if si.canonicalizing {
panic(fmt.Errorf("Scroll position canonicalize() called recursively for %s", si.name))
}
si.canonicalizing = true
defer func() {
si.canonical = canonicalFromPager(pager)
si.canonicalizing = false
}()
if pager.Reader().GetLineCount() == 0 {
si.lineIndex = nil
si.deltaScreenLines = 0
return
}
if si.lineIndex == nil {
// We have lines, but no line number, start at the top
si.lineIndex = &linemetadata.Index{}
}
si.handleNegativeDeltaScreenLines(pager)
si.handlePositiveDeltaScreenLines(pager)
emptyBottomLinesCount := si.emptyBottomLinesCount(pager)
if emptyBottomLinesCount > 0 {
// First, adjust deltaScreenLines to get us to the top
si.deltaScreenLines -= emptyBottomLinesCount
// Then, actually go up that many lines
si.handleNegativeDeltaScreenLines(pager)
}
}
func scrollPositionFromIndex(name string, index linemetadata.Index) *scrollPosition {
return &scrollPosition{
internalDontTouch: scrollPositionInternal{
name: name,
lineIndex: &index,
},
}
}
// Line index in the input stream, or nil if nothing has been read
func (p *Pager) lineIndex() *linemetadata.Index {
p.scrollPosition.internalDontTouch.canonicalize(p)
return p.scrollPosition.internalDontTouch.lineIndex
}
// Line index in the input stream, or nil if nothing has been read
func (sp *scrollPosition) lineIndex(pager *Pager) *linemetadata.Index {
sp.internalDontTouch.canonicalize(pager)
return sp.internalDontTouch.lineIndex
}
// Scroll this many screen lines before rendering
//
// Always >= 0.
func (p *Pager) deltaScreenLines() int {
p.scrollPosition.internalDontTouch.canonicalize(p)
return p.scrollPosition.internalDontTouch.deltaScreenLines
}
func (p *Pager) scrollToEnd() {
inputLineCount := p.Reader().GetLineCount()
if inputLineCount == 0 {
return
}
lastInputIndex := *linemetadata.IndexFromLength(inputLineCount)
lastInputLine := p.Reader().GetLine(lastInputIndex)
p.scrollPosition.internalDontTouch.lineIndex = &lastInputIndex
// Scroll down enough. We know for sure the last line won't wrap into more
// lines than the number of characters it contains.
p.scrollPosition.internalDontTouch.deltaScreenLines = len(lastInputLine.Line.Plain(lastInputIndex))
if p.TargetLine == nil {
// Start following the end of the file
//
// Otherwise, if we're already aiming for some place, don't overwrite
// that.
maxLineIndex := linemetadata.IndexMax()
p.setTargetLine(&maxLineIndex)
}
}
// Can be either because Pager.scrollToEnd() was just called or because the user
// has pressed the down arrow enough times.
func (p *Pager) isScrolledToEnd() bool {
inputLineCount := p.Reader().GetLineCount()
if inputLineCount == 0 {
// No lines available, which means we can't scroll any further down
return true
}
lastInputLineIndex := *linemetadata.IndexFromLength(inputLineCount)
visibleLines := p.renderLines().lines
if len(visibleLines) == 0 {
// This can happen when terminal height is 1. In that case, the status
// bar takes up that single line, leaving no room for contents.
//
// The actual return value here was picked based on that it prevents the
// crash reported here: https://github.com/walles/moor/issues/378
return true
}
lastVisibleLine := visibleLines[len(visibleLines)-1]
if lastVisibleLine.inputLineIndex != lastInputLineIndex {
// Last input line is not on the screen
return false
}
// Last line is on screen, now we need to figure out whether we can see all
// of it
lastInputLine := p.Reader().GetLine(lastInputLineIndex)
lastInputLineRendered := p.renderLine(*lastInputLine, p.getLineNumberPrefixLength(lastInputLine.Number), true)
lastRenderedSubLine := lastInputLineRendered[len(lastInputLineRendered)-1]
// If the last visible subline is the same as the last possible subline then
// we're at the bottom
return lastVisibleLine.wrapIndex == lastRenderedSubLine.wrapIndex
}
// Returns nil if there are no lines
func (p *Pager) getLastVisiblePosition() *scrollPosition {
rendered := p.renderLines()
if len(rendered.lines) == 0 {
return nil
}
lastRenderedLine := rendered.lines[len(rendered.lines)-1]
return &scrollPosition{
internalDontTouch: scrollPositionInternal{
name: "Last Visible Position",
lineIndex: &lastRenderedLine.inputLineIndex,
deltaScreenLines: lastRenderedLine.wrapIndex,
},
}
}
func (si *scrollPositionInternal) getMaxNumberPrefixLength(pager *Pager) int {
maxPossibleIndex := *linemetadata.IndexFromLength(pager.Reader().GetLineCount())
// This is an approximation assuming we don't do any wrapping. Finding the
// real answer while wrapping requires rendering, which requires the real
// answer and so on, so we do an approximation here to save us from
// recursion.
//
// Let's improve on demand.
var index linemetadata.Index
// Ref: https://github.com/walles/moor/issues/198
if si.lineIndex != nil {
index = *si.lineIndex
}
// Not going back when computing the max number prefix length prevents
// https://github.com/walles/moor/issues/338.
//
// FIXME: Is there something better we should be doing instead?
positiveDeltaScreenLines := si.deltaScreenLines
if positiveDeltaScreenLines < 0 {
positiveDeltaScreenLines = 0
}
maxVisibleIndex := index.NonWrappingAdd(
positiveDeltaScreenLines +
pager.visibleHeight() - 1)
if maxVisibleIndex.IsAfter(maxPossibleIndex) {
maxVisibleIndex = maxPossibleIndex
}
var number linemetadata.Number
lastVisibleLine := pager.Reader().GetLine(maxVisibleIndex)
// nil can happen when the input stream is empty
if lastVisibleLine != nil {
number = lastVisibleLine.Number
}
// Count the length of the last line number
return pager.getLineNumberPrefixLength(number)
}
moor-2.10.3/internal/scrollPosition_test.go 0000664 0000000 0000000 00000011135 15135744745 0021016 0 ustar 00root root 0000000 0000000 package internal
import (
"fmt"
"strings"
"testing"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/reader"
"github.com/walles/moor/v2/twin"
"gotest.tools/v3/assert"
)
const screenHeight = 60
// Repro for: https://github.com/walles/moor/issues/166
func testCanonicalize1000(t *testing.T, withStatusBar bool, currentStartLine linemetadata.Index, lastVisibleLine linemetadata.Index) {
pager := Pager{}
pager.screen = twin.NewFakeScreen(100, screenHeight)
pager.readers = []*reader.ReaderImpl{reader.NewFromTextForTesting("test", strings.Repeat("a\n", 2000))}
pager.filteringReader = FilteringReader{
BackingReader: pager.readers[pager.currentReader],
Filter: &pager.filter,
}
pager.ShowLineNumbers = true
pager.showLineNumbers = true
pager.ShowStatusBar = withStatusBar
pager.scrollPosition = scrollPosition{
internalDontTouch: scrollPositionInternal{
lineIndex: ¤tStartLine,
deltaScreenLines: 0,
name: "findFirstHit",
canonicalizing: false,
},
}
lastVisiblePosition := scrollPosition{
internalDontTouch: scrollPositionInternal{
lineIndex: &lastVisibleLine,
deltaScreenLines: 0,
name: "Last Visible Position",
},
}
assert.Equal(t, *lastVisiblePosition.lineIndex(&pager), lastVisibleLine)
}
func TestCanonicalize1000WithStatusBar(t *testing.T) {
for startLine := 0; startLine < 1500; startLine++ {
t.Run(fmt.Sprint("startLine=", startLine), func(t *testing.T) {
testCanonicalize1000(t, true,
linemetadata.IndexFromZeroBased(startLine),
linemetadata.IndexFromZeroBased(startLine+screenHeight-2),
)
})
}
}
func TestCanonicalize1000WithoutStatusBar(t *testing.T) {
for startLine := 0; startLine < 1500; startLine++ {
t.Run(fmt.Sprint("startLine=", startLine), func(t *testing.T) {
testCanonicalize1000(t, true,
linemetadata.IndexFromZeroBased(startLine),
linemetadata.IndexFromZeroBased(startLine+screenHeight-1),
)
})
}
}
// Try scrolling between two points, on a 80 x screenHeight screen with 1492
// lines of input.
func tryScrollAmount(t *testing.T, scrollFrom linemetadata.Index, scrollDistance int) {
// Create 1492 lines of single-char content
pager := Pager{}
pager.screen = twin.NewFakeScreen(80, screenHeight)
pager.readers = []*reader.ReaderImpl{reader.NewFromTextForTesting("test", strings.Repeat("x\n", 1492))}
pager.filteringReader = FilteringReader{
BackingReader: pager.readers[pager.currentReader],
Filter: &pager.filter,
}
pager.ShowLineNumbers = true
pager.showLineNumbers = true
pager.scrollPosition = scrollPosition{
internalDontTouch: scrollPositionInternal{
name: "tryScrollAmount",
lineIndex: &scrollFrom,
deltaScreenLines: scrollDistance,
},
}
// Trigger rendering (and canonicalization). If the prefix is miscomputed
// this would previously panic inside createLinePrefix().
rendered := pager.renderLines()
// Sanity check the result
assert.Assert(t, rendered.lines != nil)
assert.Equal(t, len(rendered.lines), pager.visibleHeight())
assert.Equal(t, rendered.lines[0].inputLineIndex, scrollFrom.NonWrappingAdd(scrollDistance))
}
// Repro for https://github.com/walles/moor/issues/313: Rapid scroll
// (deltaScreenLines > 0) crossing from 3 to 4 digits must not panic due to
// too-short number prefix length.
func TestFastScrollAcross1000DoesNotPanic(t *testing.T) {
tryScrollAmount(t, linemetadata.IndexFromZeroBased(900), 200)
}
// Repro for https://github.com/walles/moor/issues/338
func TestIssue338(t *testing.T) {
tryScrollAmount(t, linemetadata.IndexFromZeroBased(1000), -60)
}
func TestMultipleScrollStartsAcross1000DoNotPanic(t *testing.T) {
for scrollFrom := 1000 - screenHeight - 10; scrollFrom <= 1000; scrollFrom++ {
tryScrollAmount(t, linemetadata.IndexFromZeroBased(scrollFrom), screenHeight)
}
}
func TestMultipleScrollDistancesAcross1000DoNotPanic(t *testing.T) {
scrollFrom := 1000 - screenHeight - 10
for scrollDistance := 0; scrollDistance <= 3*screenHeight; scrollDistance++ {
tryScrollAmount(t, linemetadata.IndexFromZeroBased(scrollFrom), scrollDistance)
}
}
func TestMultipleBackwardsScrollStartsAcross1000DoNotPanic(t *testing.T) {
for scrollFrom := 1000 + screenHeight + 10; scrollFrom >= 1000; scrollFrom-- {
tryScrollAmount(t, linemetadata.IndexFromZeroBased(scrollFrom), -screenHeight)
}
}
func TestMultipleBackwardsScrollDistancesAcross1000DoNotPanic(t *testing.T) {
scrollFrom := 1000 + screenHeight + 10
for scrollDistance := 0; scrollDistance <= 3*screenHeight; scrollDistance++ {
tryScrollAmount(t, linemetadata.IndexFromZeroBased(scrollFrom), -scrollDistance)
}
}
moor-2.10.3/internal/search-history.go 0000664 0000000 0000000 00000021304 15135744745 0017677 0 ustar 00root root 0000000 0000000 package internal
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
"github.com/adrg/xdg"
log "github.com/sirupsen/logrus"
)
type SearchHistory struct {
// Empty means no history file. Set by BootSearchHistory().
absFileName string
entries []string
}
/*
Search history semantics:
- On startup, load history or import from other pager
- Any change to the input box resets the history index to "past the end"
- If the user starts typing, then arrows up once and down once, whatever the
user typed should still be there.
- On importing from less, remove unprintable characters
- On exiting search, no matter how, add a new entry at the end and deduplicate.
Save to disk.
*/
const maxSearchHistoryEntries = 640 // This should be enough for anyone
// A relative path or just a file name means relative to the user's home
// directory. Empty means follow the XDG spec for data files.
func BootSearchHistory(fileName string) SearchHistory {
fileName = resolveHistoryFilePath(fileName)
history, err := loadMoorSearchHistory(fileName)
if err != nil {
log.Infof("Could not load moor search history from %s: %v", fileName, err)
// IO Error, give up
return SearchHistory{}
}
if history != nil {
log.Infof("Loaded %d search history entries from %s", len(history), fileName)
return SearchHistory{
absFileName: fileName,
entries: history,
}
}
history, err = loadLessSearchHistory()
if err != nil {
log.Infof("Could not import less search history: %v", err)
return SearchHistory{
absFileName: fileName,
entries: []string{},
}
}
if history == nil {
// Neither moor nor less history found, so we start a new history file
// from scratch
return SearchHistory{
absFileName: fileName,
entries: []string{},
}
}
log.Infof("Imported %d search history entries from less", len(history))
return SearchHistory{
absFileName: fileName,
entries: history,
}
}
// Returns (nil, nil) if the file doesn't exist. Otherwise returns history slice
// or error.
func loadMoorSearchHistory(absHistoryFileName string) ([]string, error) {
if absHistoryFileName == "" {
// No history file
return nil, nil
}
lines := []string{}
err := iterateFileByLines(absHistoryFileName, func(line string) {
if len(line) > 640 {
// Line too long, 640 chars should be enough for anyone
return
}
lines = append(lines, line)
if len(lines) > maxSearchHistoryEntries {
// Throw away the first (oldest) history line, we don't want more
// than this
lines = lines[1:]
}
})
if errors.Is(err, os.ErrNotExist) {
// No history file found, not a problem but no history either, return
// nil rather than empty slice
return nil, nil
}
if err != nil {
return nil, err
}
return removeDupsKeepingLast(lines), nil
}
// Empty file name will resolve to a default XDG friendly path. Absolute will be
// left untouched. Relative will be interpreted relative to the user's home
// directory.
func resolveHistoryFilePath(fileName string) string {
if fileName == "-" || fileName == "/dev/null" {
// No history file
return ""
}
if fileName == "" {
xdgPath, err := xdg.DataFile("moor/search_history")
if err != nil {
log.Infof("Could not resolve XDG data file path for search history: %v", err)
return ""
}
return xdgPath
}
if filepath.IsAbs(fileName) {
return fileName
}
// Path relative to home directory
home, err := os.UserHomeDir()
if err != nil {
log.Infof("Could not get user home dir to resolve history file path: %v", err)
return ""
}
return filepath.Join(home, fileName)
}
// Return a new string with any unprintable characters removed
func withoutUnprintables(s string) string {
var builder strings.Builder
for _, r := range s {
if unicode.IsPrint(r) {
builder.WriteRune(r)
}
}
return builder.String()
}
// File format ref: https://unix.stackexchange.com/a/246641/384864
func loadLessSearchHistory() ([]string, error) {
lessHistFileValue := os.Getenv("LESSHISTFILE")
if lessHistFileValue == "/dev/null" {
// No less history file
return nil, nil
}
if lessHistFileValue == "-" {
// No less history file
return nil, nil
}
fileNames := []string{".lesshst", "_lesshst"}
if lessHistFileValue != "" {
// Check the user-specified file first
fileNames = append([]string{lessHistFileValue}, fileNames...)
}
for _, fileName := range fileNames {
lines := []string{}
err := iterateFileByLines(fileName, func(line string) {
if !strings.HasPrefix(line, "\"") {
// Not a search history line
return
}
if len(line) > 640 {
// Line too long, 640 chars should be enough for anyone
return
}
lines = append(lines, withoutUnprintables(line[1:])) // Strip leading "
if len(lines) > maxSearchHistoryEntries {
// Throw away the first (oldest) history line, we don't want more
// than this
lines = lines[1:]
}
})
if errors.Is(err, os.ErrNotExist) {
// No such file, try next
continue
}
if err != nil {
return nil, err
}
return removeDupsKeepingLast(lines), nil
}
// No history files found, not a problem but no history either, return
return nil, nil
}
// path can be relative or absolute, or just a single file name (also relative).
// If path is relative, treat it as relative to the user's home directory
func iterateFileByLines(path string, processLine func(string)) error {
if !filepath.IsAbs(path) {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("could not get user home dir for iterating %s: %w", path, err)
}
path = filepath.Join(home, path)
}
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("could not open %s for iteration: %w", path, err)
}
defer func() {
err := f.Close()
if err != nil {
log.Warnf("closing %s failed when iterating: %v", path, err)
}
}()
scanner := bufio.NewScanner(f)
counter := 0
for scanner.Scan() {
line := scanner.Text()
if len(line) == 0 {
continue
}
processLine(line)
counter++
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scan %s: %w", path, err)
}
log.Debugf("%d lines of search history processed from %s", counter, path)
return nil
}
// If there are duplicates, retain only the last of each
func removeDupsKeepingLast(history []string) []string {
if history == nil {
return nil
}
seen := make(map[string]bool)
cleaned := make([]string, 0, len(history))
cleanCount := 0
// Iterate backwards to keep the last occurrence
for i := len(history) - 1; i >= 0; i-- {
entry := history[i]
if !seen[entry] {
seen[entry] = true
cleaned = append(cleaned, entry)
} else {
cleanCount++
}
}
// Reverse the cleaned slice to restore original order
for i, j := 0, len(cleaned)-1; i < j; i, j = i+1, j-1 {
cleaned[i], cleaned[j] = cleaned[j], cleaned[i]
}
log.Debugf("Removed %d redundant search history lines", cleanCount)
return cleaned
}
func (h *SearchHistory) addEntry(entry string) {
if entry == "" {
return
}
if len(h.entries) > 0 && h.entries[len(h.entries)-1] == entry {
// Same as last entry, do nothing
return
}
// Append the new entry in-memory
h.entries = removeDupsKeepingLast(append(h.entries, entry))
for len(h.entries) > maxSearchHistoryEntries {
// Remove oldest entry
h.entries = h.entries[1:]
}
if os.Getenv("LESSSECURE") == "1" {
// LESSSECURE=1 means not writing anything to disk
return
}
if h.absFileName == "" {
// No history file configured
return
}
// Write new file to a temp file and rename it into place
tmpFilePath := h.absFileName + ".tmp"
f, err := os.Create(tmpFilePath)
if err != nil {
log.Infof("Could not create temp history file %s: %v", tmpFilePath, err)
return
}
// Prevent others from reading your history file. Best effort, if this
// fails it fails.
_ = f.Chmod(0o600)
shouldRename := true
defer func() {
err := f.Close()
if err != nil {
// If close fails we don't really know what's in the temp file
log.Infof("Could not close temp history file %s, giving up: %v", tmpFilePath, err)
return
}
if shouldRename {
// Rename temp file into place
err = os.Rename(tmpFilePath, h.absFileName)
if err != nil {
log.Infof("Could not rename temp history file %s to %s: %v", tmpFilePath, h.absFileName, err)
return
}
} else {
// Remove temp file
err = os.Remove(tmpFilePath)
if err != nil {
log.Infof("Could not remove temp history file %s: %v", tmpFilePath, err)
}
}
}()
writer := bufio.NewWriter(f)
for _, line := range h.entries {
_, err := writer.WriteString(line + "\n")
if err != nil {
log.Infof("Could not write to temp history file %s: %v", tmpFilePath, err)
shouldRename = false
return
}
}
err = writer.Flush()
if err != nil {
log.Infof("Could not flush to temp history file %s: %v", tmpFilePath, err)
shouldRename = false
return
}
}
moor-2.10.3/internal/search-line-cache.go 0000664 0000000 0000000 00000004217 15135744745 0020172 0 ustar 00root root 0000000 0000000 package internal
import (
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/reader"
)
// For small searches or few cores, search will be fast no matter what we put
// here. For large searches on many-core systems, a larger cache will help
// performance. But a larger cache without a performance increase has no value.
// To evaluate:
//
// go test -run='^$' -bench 'Warm' ./internal
//
// Results from Johan's laptop. The numbers are the test iteration counts for
// BenchmarkHighlightedWarmSearch and BenchmarkPlainTextWarmSearch. The
// optimization has been done to improve the sum of these two benchmarks.
//
// 100: 741+726=1467
// 500: 832+882=1714 <-- Best
// 1000: 754+810=1564
// 5000: 637+658=1295
const searchLineCacheSize = 500
type searchLineCache struct {
lines []reader.NumberedLine
}
func (c *searchLineCache) GetLine(reader reader.Reader, index linemetadata.Index, direction SearchDirection) *reader.NumberedLine {
// Do we have a cache hit?
cacheHit := c.getLineFromCache(index)
if cacheHit != nil {
return cacheHit
}
// Cache miss, load new lines
firstIndexToRequest := index
if direction == SearchDirectionBackward {
// Let's say we want index 10 to be in the cache. Cache size is 5.
// Then, the first index must be 6, so that we get 6,7,8,9,10.
// Or in other words, 10 - 5 + 1 = 6.
firstIndexToRequest = index.NonWrappingAdd(-searchLineCacheSize + 1)
}
reader.GetLinesPreallocated(firstIndexToRequest, &c.lines)
// Get the line from the cache
return c.getLineFromCache(index)
}
// Or nil if that line isn't in the cache
func (c *searchLineCache) getLineFromCache(index linemetadata.Index) *reader.NumberedLine {
if cap(c.lines) != searchLineCacheSize {
// Initialize cache
c.lines = make([]reader.NumberedLine, 0, searchLineCacheSize)
}
if len(c.lines) == 0 {
return nil
}
firstCachedIndexInclusive := c.lines[0].Index
if index.IsBefore(firstCachedIndexInclusive) {
return nil
}
lastCachedIndexInclusive := c.lines[len(c.lines)-1].Index
if index.IsAfter(lastCachedIndexInclusive) {
return nil
}
return &c.lines[index.Index()-firstCachedIndexInclusive.Index()]
}
moor-2.10.3/internal/search-linescanner.go 0000664 0000000 0000000 00000011614 15135744745 0020502 0 ustar 00root root 0000000 0000000 // This file contains code for scanning input lines for search hits.
package internal
import (
"fmt"
"runtime"
"runtime/debug"
"time"
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/reader"
"github.com/walles/moor/v2/internal/search"
)
// Search input lines. Not screen lines!
//
// The `beforePosition` parameter is exclusive, meaning that line will not be
// searched.
//
// For the actual searching, this method will call _findFirstHit() in parallel
// on multiple cores, to help large file search performance.
func FindFirstHit(reader reader.Reader, search search.Search, startPosition linemetadata.Index, beforePosition *linemetadata.Index, direction SearchDirection) *linemetadata.Index {
// If the number of lines to search matches the number of cores (or more),
// divide the search into chunks. Otherwise use one chunk.
chunkCount := runtime.NumCPU()
var linesCount int
if direction == SearchDirectionBackward {
// If the startPosition is zero, that should make the count one
linesCount = startPosition.Index() + 1
if beforePosition != nil {
// Searching from 1 with before set to 0 should make the count 1
linesCount = startPosition.Index() - beforePosition.Index()
}
} else {
linesCount = reader.GetLineCount() - startPosition.Index()
if beforePosition != nil {
// Searching from 1 with before set to 2 should make the count 1
linesCount = beforePosition.Index() - startPosition.Index()
}
}
if linesCount < chunkCount {
chunkCount = 1
}
chunkSize := linesCount / chunkCount
log.Debugf("Searching %d lines across %d cores with %d lines per core...", linesCount, chunkCount, chunkSize)
t0 := time.Now()
defer func() {
dt := time.Since(t0)
linesPerSecond := float64(linesCount) / dt.Seconds()
linesPerSecondS := fmt.Sprintf("%.0f", linesPerSecond)
if linesPerSecond > 7_000_000_000.0 {
linesPerSecondS = fmt.Sprintf("%.0fG", linesPerSecond/1000_000_000.0)
} else if linesPerSecond > 7_000_000.0 {
linesPerSecondS = fmt.Sprintf("%.0fM", linesPerSecond/1000_000.0)
} else if linesPerSecond > 7_000.0 {
linesPerSecondS = fmt.Sprintf("%.0fk", linesPerSecond/1000.0)
}
if linesCount > 0 {
log.Debugf("Searched %d lines in %s at %slines/s or %s/line",
linesCount,
dt,
linesPerSecondS,
(dt / time.Duration(linesCount)).String())
} else {
log.Debugf("Searched %d lines in %s at %slines/s", linesCount, dt, linesPerSecondS)
}
}()
// Each parallel search will start at one of these positions
searchStarts := make([]linemetadata.Index, chunkCount)
directionSign := 1
if direction == SearchDirectionBackward {
directionSign = -1
}
for i := 0; i < chunkCount; i++ {
searchStarts[i] = startPosition.NonWrappingAdd(i * directionSign * chunkSize)
}
// Make a results array, with one result per chunk
findings := make([]chan *linemetadata.Index, chunkCount)
// Search all chunks in parallel
for i, searchStart := range searchStarts {
findings[i] = make(chan *linemetadata.Index)
searchEndIndex := i + 1
var chunkBefore *linemetadata.Index
if searchEndIndex < len(searchStarts) {
chunkBefore = &searchStarts[searchEndIndex]
} else if beforePosition != nil {
chunkBefore = beforePosition
}
go func(i int, searchStart linemetadata.Index, chunkBefore *linemetadata.Index) {
defer func() {
PanicHandler("findFirstHit()/chunkSearch", recover(), debug.Stack())
}()
findings[i] <- _findFirstHit(reader, searchStart, search, chunkBefore, direction)
}(i, searchStart, chunkBefore)
}
// Return the first non-nil result
for _, finding := range findings {
result := <-finding
if result != nil {
return result
}
}
return nil
}
// NOTE: When we search, we do that by looping over the *input lines*, not the
// screen lines. That's why startPosition is an Index rather than a
// scrollPosition.
//
// The `beforePosition` parameter is exclusive, meaning that line will not be
// searched.
//
// This method will run over multiple chunks of the input file in parallel to
// help large file search performance.
func _findFirstHit(reader reader.Reader, startPosition linemetadata.Index, search search.Search, beforePosition *linemetadata.Index, direction SearchDirection) *linemetadata.Index {
searchPosition := startPosition
lineCache := searchLineCache{}
for {
line := lineCache.GetLine(reader, searchPosition, direction)
if line == nil {
// No match, give up
return nil
}
lineText := line.Plain()
if search.Matches(lineText) {
return &searchPosition
}
if direction == SearchDirectionForward {
searchPosition = searchPosition.NonWrappingAdd(1)
} else {
if (searchPosition == linemetadata.Index{}) {
// Reached the top without any match, give up
return nil
}
searchPosition = searchPosition.NonWrappingAdd(-1)
}
if beforePosition != nil && searchPosition == *beforePosition {
// No match, give up
return nil
}
}
}
moor-2.10.3/internal/search-linescanner_test.go 0000664 0000000 0000000 00000012302 15135744745 0021534 0 ustar 00root root 0000000 0000000 package internal
import (
"fmt"
"os"
"runtime"
"strings"
"testing"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/internal/reader"
"github.com/walles/moor/v2/internal/search"
"github.com/walles/moor/v2/twin"
"gotest.tools/v3/assert"
log "github.com/sirupsen/logrus"
)
func TestFindFirstHitSimple(t *testing.T) {
reader := reader.NewFromTextForTesting("TestFindFirstHitSimple", "AB")
assert.NilError(t, reader.Wait())
hit := FindFirstHit(reader, search.For("AB"), linemetadata.Index{}, nil, SearchDirectionForward)
assert.Assert(t, hit.IsZero())
}
func TestFindFirstHitAnsi(t *testing.T) {
reader := reader.NewFromTextForTesting("", "A\x1b[30mB")
assert.NilError(t, reader.Wait())
hit := FindFirstHit(reader, search.For("AB"), linemetadata.Index{}, nil, SearchDirectionForward)
assert.Assert(t, hit.IsZero())
}
func TestFindFirstHitNoMatch(t *testing.T) {
reader := reader.NewFromTextForTesting("TestFindFirstHitSimple", "AB")
assert.NilError(t, reader.Wait())
hit := FindFirstHit(reader, search.For("this pattern should not be found"), linemetadata.Index{}, nil, SearchDirectionForward)
assert.Assert(t, hit == nil)
}
func TestFindFirstHitNoMatchBackwards(t *testing.T) {
reader := reader.NewFromTextForTesting("TestFindFirstHitSimple", "AB")
assert.NilError(t, reader.Wait())
theEnd := *linemetadata.IndexFromLength(reader.GetLineCount())
hit := FindFirstHit(reader, search.For("this pattern should not be found"), theEnd, nil, SearchDirectionBackward)
assert.Assert(t, hit == nil)
}
// Converts a cell row to a plain string and removes trailing whitespace.
func rowToString(row []twin.StyledRune) string {
rowString := ""
for _, cell := range row {
rowString += string(cell.Rune)
}
return strings.TrimRight(rowString, " ")
}
func benchmarkSearch(b *testing.B, highlighted bool, warm bool) {
log.SetLevel(log.WarnLevel) // Stop info logs from polluting benchmark output
// Pick a go file so we get something with highlighting
_, sourceFilename, _, ok := runtime.Caller(0)
if !ok {
panic("Getting current filename failed")
}
sourceBytes, err := os.ReadFile(sourceFilename)
assert.NilError(b, err)
fileContents := string(sourceBytes)
// Repeat input enough times to get to some target size, before highlighting
// to get the same amount of text in either case
replications := 5_000_000 / len(fileContents)
if highlighted {
highlightedSourceCode, err := reader.Highlight(fileContents, *styles.Get("native"), formatters.TTY16m, lexers.Get("go"))
assert.NilError(b, err)
if highlightedSourceCode == nil {
panic("Highlighting didn't want to, returned nil")
}
fileContents = *highlightedSourceCode
}
if !warm {
// This makes the ns/op benchmark numbers more comparable between plain
// and highlighted in the cold case.
replications = 5_000_000 / len(fileContents)
}
// Create some input to search. Use a Builder to avoid quadratic string concatenation time.
var builder strings.Builder
builder.Grow(len(fileContents) * replications)
for range replications {
builder.WriteString(fileContents)
}
testString := builder.String()
benchMe := reader.NewFromTextForTesting("hello", testString)
assert.NilError(b, benchMe.Wait())
// The target string is split to not match, remember we're searching through
// this very file. Same string as in BenchmarkCaseInsensitiveSubstringMatch
// in search/search_test.go.
//
// NOTE: The search term is all lowercase to trigger case-insensitive
// search. I believe this is the most common case, so that's what we should
// benchmark.
search := search.For("this won't match " + "anything")
reader.DisablePlainCachingForBenchmarking = !warm
if warm {
// Warm up any caches etc by doing one search before we start measuring
hit := FindFirstHit(benchMe, search, linemetadata.Index{}, nil, SearchDirectionForward)
if hit != nil {
panic(fmt.Errorf("This test is meant to scan the whole file without finding anything"))
}
}
// I hope forcing a GC here will make numbers more predictable
runtime.GC()
b.SetBytes(int64(len(testString)))
b.ResetTimer()
for range b.N {
// This test will search through all the N copies we made of our file
hit := FindFirstHit(benchMe, search, linemetadata.Index{}, nil, SearchDirectionForward)
if hit != nil {
panic(fmt.Errorf("This test is meant to scan the whole file without finding anything"))
}
}
}
// How long does it take to search a highlighted file for some regex the first time?
//
// Run with: go test -run='^$' -bench=. . ./...
func BenchmarkHighlightedColdSearch(b *testing.B) {
benchmarkSearch(b, true, false)
}
// How long does it take to search a plain text file for some regex the first time?
//
// Search performance was a problem for me when I had a 600MB file to search in.
//
// Run with: go test -run='^$' -bench=. . ./...
func BenchmarkPlainTextColdSearch(b *testing.B) {
benchmarkSearch(b, false, false)
}
// How long does it take to search a plain text file for some regex the second time?
//
// Run with: go test -run='^$' -bench=. . ./...
func BenchmarkPlainTextWarmSearch(b *testing.B) {
benchmarkSearch(b, false, true)
}
moor-2.10.3/internal/search/ 0000775 0000000 0000000 00000000000 15135744745 0015651 5 ustar 00root root 0000000 0000000 moor-2.10.3/internal/search/matchRanges.go 0000664 0000000 0000000 00000001055 15135744745 0020435 0 ustar 00root root 0000000 0000000 package search
// MatchRanges collects match indices
type MatchRanges struct {
Matches [][2]int
}
// InRange says true if the index is part of a regexp match
func (mr *MatchRanges) InRange(index int) bool {
if mr == nil {
return false
}
for _, match := range mr.Matches {
matchFirstIndex := match[0]
matchLastIndex := match[1] - 1
if index < matchFirstIndex {
continue
}
if index > matchLastIndex {
continue
}
return true
}
return false
}
func (mr *MatchRanges) Empty() bool {
return mr == nil || len(mr.Matches) == 0
}
moor-2.10.3/internal/search/matchRanges_test.go 0000664 0000000 0000000 00000010055 15135744745 0021474 0 ustar 00root root 0000000 0000000 package search
import (
"testing"
"gotest.tools/v3/assert"
)
// This is really a constant, don't change it!
var _TestString = "mamma"
func TestGetMatchRanges(t *testing.T) {
matchRanges := For("m+").GetMatchRanges(_TestString)
assert.Equal(t, len(matchRanges.Matches), 2) // Two matches
assert.DeepEqual(t, matchRanges.Matches[0][0], 0) // First match starts at 0
assert.DeepEqual(t, matchRanges.Matches[0][1], 1) // And ends on 1 exclusive
assert.DeepEqual(t, matchRanges.Matches[1][0], 2) // Second match starts at 2
assert.DeepEqual(t, matchRanges.Matches[1][1], 4) // And ends on 4 exclusive
}
func TestGetMatchRangesNilPattern(t *testing.T) {
matchRanges := For("").GetMatchRanges(_TestString)
assert.Assert(t, matchRanges == nil)
assert.Assert(t, !matchRanges.InRange(0))
}
func TestInRange(t *testing.T) {
// Should match the one in TestGetMatchRanges()
matchRanges := For("m+").GetMatchRanges(_TestString)
assert.Assert(t, !matchRanges.InRange(-1)) // Before start
assert.Assert(t, matchRanges.InRange(0)) // m
assert.Assert(t, !matchRanges.InRange(1)) // a
assert.Assert(t, matchRanges.InRange(2)) // m
assert.Assert(t, matchRanges.InRange(3)) // m
assert.Assert(t, !matchRanges.InRange(4)) // a
assert.Assert(t, !matchRanges.InRange(5)) // After end
}
func TestUtf8(t *testing.T) {
// This test verifies that the match ranges are by rune rather than by byte
unicodes := "-ä-ä-"
matchRanges := For("ä").GetMatchRanges(unicodes)
assert.Assert(t, !matchRanges.InRange(0)) // -
assert.Assert(t, matchRanges.InRange(1)) // ä
assert.Assert(t, !matchRanges.InRange(2)) // -
assert.Assert(t, matchRanges.InRange(3)) // ä
assert.Assert(t, !matchRanges.InRange(4)) // -
}
func TestNoMatch(t *testing.T) {
// This test verifies that the match ranges are by rune rather than by byte
unicodes := "gris"
matchRanges := For("apa").GetMatchRanges(unicodes)
assert.Assert(t, !matchRanges.InRange(0))
assert.Assert(t, !matchRanges.InRange(1))
assert.Assert(t, !matchRanges.InRange(2))
assert.Assert(t, !matchRanges.InRange(3))
assert.Assert(t, !matchRanges.InRange(4))
}
func TestEndMatch(t *testing.T) {
// This test verifies that the match ranges are by rune rather than by byte
unicodes := "-ä"
matchRanges := For("ä").GetMatchRanges(unicodes)
assert.Assert(t, !matchRanges.InRange(0)) // -
assert.Assert(t, matchRanges.InRange(1)) // ä
assert.Assert(t, !matchRanges.InRange(2)) // Past the end
}
func TestRealWorldBug(t *testing.T) {
// Verify a real world bug found in v1.9.8
testString := "anna"
matchRanges := For("n").GetMatchRanges(testString)
assert.Equal(t, len(matchRanges.Matches), 2) // Two matches
assert.DeepEqual(t, matchRanges.Matches[0][0], 1) // First match starts at 1
assert.DeepEqual(t, matchRanges.Matches[0][1], 2) // And ends on 2 exclusive
assert.DeepEqual(t, matchRanges.Matches[1][0], 2) // Second match starts at 2
assert.DeepEqual(t, matchRanges.Matches[1][1], 3) // And ends on 3 exclusive
}
func TestMatchRanges_CaseSensitiveRegex(t *testing.T) {
matchRanges := For("G.*S").GetMatchRanges("GRIIIS")
assert.Assert(t, len(matchRanges.Matches) > 0)
matchRangesLower := For("G.*S").GetMatchRanges("griiis")
assert.Assert(t, matchRangesLower == nil || len(matchRangesLower.Matches) == 0)
}
func TestMatchRanges_CaseInsensitiveRegex(t *testing.T) {
testString := "GRIIIS"
matchRanges := For("g.*s").GetMatchRanges(testString)
assert.Assert(t, len(matchRanges.Matches) > 0)
}
func TestMatchRanges_CaseSensitiveSubstring(t *testing.T) {
matchRanges := For(")G").GetMatchRanges(")G")
assert.Assert(t, len(matchRanges.Matches) == 1)
matchRangesLower := For(")G").GetMatchRanges(")g")
assert.Assert(t, matchRangesLower == nil || len(matchRangesLower.Matches) == 0)
}
func TestMatchRanges_CaseInsensitiveSubstring(t *testing.T) {
testString := ")G"
matchRanges := For(")g").GetMatchRanges(testString)
assert.Assert(t, len(matchRanges.Matches) == 1)
}
func TestMatchRanges_EmptyPattern(t *testing.T) {
testString := "anything"
matchRanges := For("").GetMatchRanges(testString)
assert.Assert(t, matchRanges == nil)
}
moor-2.10.3/internal/search/search.go 0000664 0000000 0000000 00000006766 15135744745 0017464 0 ustar 00root root 0000000 0000000 package search
import (
"regexp"
"strings"
"unicode"
"github.com/charlievieth/strcase"
)
type Search struct {
findMe string
// If this is false it means the input has to be interpreted as a regexp.
isSubstringSearch bool
hasUppercase bool
pattern *regexp.Regexp
}
func (search Search) Equals(other Search) bool {
return search.findMe == other.findMe
}
func (search Search) String() string {
return search.findMe
}
func For(s string) Search {
search := Search{}
search.For(s)
return search
}
func (search *Search) For(s string) *Search {
search.findMe = s
if s == "" {
// No search
search.pattern = nil
return search
}
var err error
hasSpecialChars := regexp.QuoteMeta(s) != s
search.pattern, err = regexp.Compile(s)
isValidRegexp := err == nil
regexpMatchingRequired := hasSpecialChars && isValidRegexp
search.isSubstringSearch = !regexpMatchingRequired
search.hasUppercase = false
for _, char := range s {
if unicode.IsUpper(char) {
search.hasUppercase = true
break
}
}
if search.isSubstringSearch {
// Pattern still needed for GetMatchRanges()
search.pattern, err = regexp.Compile(regexp.QuoteMeta(s))
if err != nil {
panic(err)
}
return search
}
// At this point we know it's a valid regexp, and that it does include
// regexp specific characters. We also know the pattern has been
// successfully compiled.
return search
}
func (search *Search) Clear() {
search.findMe = ""
search.pattern = nil
}
func (search Search) Active() bool {
return search.findMe != ""
}
func (search Search) Inactive() bool {
return search.findMe == ""
}
func (search Search) Matches(line string) bool {
if search.findMe == "" {
return false
}
if search.isSubstringSearch && search.hasUppercase {
// Case sensitive substring search
return strings.Contains(line, search.findMe)
}
if search.isSubstringSearch && !search.hasUppercase {
// Case insensitive substring search
return strcase.Contains(line, search.findMe)
}
// Regexp search
if !search.hasUppercase {
// Regexp is already lowercase, do the same to the line to make the
// search case insensitive
line = strings.ToLower(line)
}
return search.pattern.MatchString(line)
}
// getMatchRanges locates one or more regexp matches in a string
func (search Search) GetMatchRanges(String string) *MatchRanges {
if search.Inactive() {
return nil
}
if !search.hasUppercase {
// Case insensitive search, lowercase the string. The pattern is already
// lowercase whenever hasUppercase is false.
String = strings.ToLower(String)
}
return &MatchRanges{
Matches: toRunePositions(search.pattern.FindAllStringIndex(String, -1), String),
}
}
// Convert byte indices to rune indices
func toRunePositions(byteIndices [][]int, matchedString string) [][2]int {
var returnMe [][2]int
if len(byteIndices) == 0 {
// Nothing to see here, move along
return returnMe
}
runeIndex := 0
byteIndicesToRuneIndices := make(map[int]int, 0)
for byteIndex := range matchedString {
byteIndicesToRuneIndices[byteIndex] = runeIndex
runeIndex++
}
// If a match touches the end of the string, that will be encoded as one
// byte past the end of the string. Therefore we must add a mapping for
// first-index-after-the-end.
byteIndicesToRuneIndices[len(matchedString)] = runeIndex
for _, bytePair := range byteIndices {
fromRuneIndex := byteIndicesToRuneIndices[bytePair[0]]
toRuneIndex := byteIndicesToRuneIndices[bytePair[1]]
returnMe = append(returnMe, [2]int{fromRuneIndex, toRuneIndex})
}
return returnMe
}
moor-2.10.3/internal/search/search_test.go 0000664 0000000 0000000 00000003111 15135744745 0020500 0 ustar 00root root 0000000 0000000 package search
import (
"os"
"strings"
"testing"
"gotest.tools/v3/assert"
)
func TestSearchForMatch(t *testing.T) {
assert.Assert(t, For("").pattern == nil)
// Test regexp matching
assert.Assert(t, For("G.*S").Matches("GRIIIS"))
assert.Assert(t, !For("G.*S").Matches("gRIIIS"))
// Test case insensitive regexp matching
assert.Assert(t, For("g.*s").Matches("GRIIIS"))
assert.Assert(t, For("g.*s").Matches("gRIIIS"))
// Test non-regexp matching
assert.Assert(t, For(")G").Matches(")G"))
assert.Assert(t, !For(")G").Matches(")g"))
// Test case insensitive non-regexp matching
assert.Assert(t, For(")g").Matches(")G"))
assert.Assert(t, For(")g").Matches(")g"))
}
func benchmarkMatch(b *testing.B, searchTerm string) {
sourceBytes, err := os.ReadFile("../../sample-files/large-git-log-patch-no-color.txt")
assert.NilError(b, err)
fileContents := string(sourceBytes)
b.SetBytes(int64(len(fileContents)))
lines := strings.Split(fileContents, "\n")
search := For(searchTerm)
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, line := range lines {
search.Matches(line)
}
}
}
func BenchmarkCaseSensitiveSubstringMatch(b *testing.B) {
benchmarkMatch(b, "This won't match anything")
}
func BenchmarkCaseInsensitiveSubstringMatch(b *testing.B) {
// Same as in benchmarkSearch() in search-linescanner_test.go
benchmarkMatch(b, "this won't match anything")
}
func BenchmarkCaseSensitiveRegexMatch(b *testing.B) {
benchmarkMatch(b, "This [w]on't match anything")
}
func BenchmarkCaseInsensitiveRegexMatch(b *testing.B) {
benchmarkMatch(b, "this [w]on't match anything")
}
moor-2.10.3/internal/styling.go 0000664 0000000 0000000 00000022263 15135744745 0016431 0 ustar 00root root 0000000 0000000 package internal
import (
"fmt"
"os"
"strings"
"github.com/alecthomas/chroma/v2"
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/internal/textstyles"
"github.com/walles/moor/v2/twin"
)
// From LESS_TERMCAP_so, overrides statusbarStyle from the Chroma style if set
var standoutStyle *twin.Style
var lineNumbersStyle = twin.StyleDefault.WithAttr(twin.AttrDim)
// Status bar and EOF marker style
var statusbarStyle = twin.StyleDefault.WithAttr(twin.AttrReverse)
var statusbarFileStyle = twin.StyleDefault.WithAttr(twin.AttrReverse).WithAttr(twin.AttrUnderline)
var plainTextStyle = twin.StyleDefault
var searchHitStyle = twin.StyleDefault.WithAttr(twin.AttrReverse)
// This can be nil
var searchHitLineBackground *twin.Color
func setStyle(updateMe *twin.Style, envVarName string, fallback *twin.Style) {
envValue := os.Getenv(envVarName)
if envValue == "" {
if fallback != nil {
*updateMe = *fallback
}
return
}
style, err := TermcapToStyle(envValue)
if err != nil {
log.Info("Ignoring invalid ", envVarName, ": ", strings.ReplaceAll(envValue, "\x1b", "ESC"), ": ", err)
return
}
*updateMe = style
}
// With exact set, only return a style if the Chroma formatter has an explicit
// configuration for that style. Otherwise, we might return fallback styles, not
// exactly matching what you requested.
func twinStyleFromChroma(terminalBackground *twin.Color, chromaStyle *chroma.Style, chromaFormatter *chroma.Formatter, chromaToken chroma.TokenType, exact bool) *twin.Style {
if chromaStyle == nil || chromaFormatter == nil {
return nil
}
stringBuilder := strings.Builder{}
err := (*chromaFormatter).Format(&stringBuilder, chromaStyle, chroma.Literator(chroma.Token{
Type: chromaToken,
Value: "X",
}))
if err != nil {
panic(err)
}
formatted := stringBuilder.String()
cells := textstyles.StyledRunesFromString(twin.StyleDefault, formatted, nil, 0).StyledRunes
if len(cells) != 1 {
log.Warnf("Chroma formatter didn't return exactly one cell: %#v", cells)
return nil
}
inexactStyle := cells[0].Style
if inexactStyle.Background() == twin.ColorDefault && terminalBackground != nil {
// Real colors can be mixed & matched, which we do in
// Line.HighlightedTokens(). So we prefer real colors when we have them.
inexactStyle = inexactStyle.WithBackground(*terminalBackground)
}
if !exact {
return &inexactStyle
}
unstyled := twinStyleFromChroma(terminalBackground, chromaStyle, chromaFormatter, chroma.None, false)
if unstyled == nil {
panic("Chroma formatter didn't return a style for chroma.None")
}
if inexactStyle != *unstyled {
// We got something other than the style of None, return it!
return &inexactStyle
}
return nil
}
// consumeLessTermcapEnvs parses LESS_TERMCAP_xx environment variables and
// adapts the moor output accordingly.
func consumeLessTermcapEnvs(terminalBackground *twin.Color, chromaStyle *chroma.Style, chromaFormatter *chroma.Formatter) {
// Requested here: https://github.com/walles/moor/issues/14
setStyle(
&textstyles.ManPageBold,
"LESS_TERMCAP_md",
twinStyleFromChroma(terminalBackground, chromaStyle, chromaFormatter, chroma.GenericStrong, false),
)
setStyle(&textstyles.ManPageUnderline,
"LESS_TERMCAP_us",
twinStyleFromChroma(terminalBackground, chromaStyle, chromaFormatter, chroma.GenericUnderline, false),
)
// Since standoutStyle defaults to nil we can't just pass it to setStyle().
// Instead we give it special treatment here and set it only if its
// environment variable is set.
//
// Ref: https://github.com/walles/moor/issues/171
envValue := os.Getenv("LESS_TERMCAP_so")
if envValue != "" {
style, err := TermcapToStyle(envValue)
if err == nil {
log.Trace("Standout style set from LESS_TERMCAP_so: ", style)
standoutStyle = &style
} else {
log.Info("Ignoring invalid LESS_TERMCAP_so: ", strings.ReplaceAll(envValue, "\x1b", "ESC"), ": ", err)
}
}
}
func getOppositeColor(base twin.Color) twin.Color {
if base == twin.ColorDefault {
panic("can't get opposite of default color")
}
white := twin.NewColor24Bit(255, 255, 255)
black := twin.NewColor24Bit(0, 0, 0)
if base.Distance(white) > base.Distance(black) {
// Foreground is far away from white, so pretend the background is white
return white
} else {
// Foreground is far away from black, so pretend the background is black
return black
}
}
func styleUI(terminalBackground *twin.Color, chromaStyle *chroma.Style, chromaFormatter *chroma.Formatter, statusbarOption StatusBarOption, withTerminalFg bool, configureSearchHitLineBackground bool) {
// Set defaults
plainTextStyle = twin.StyleDefault
textstyles.ManPageHeading = twin.StyleDefault.WithAttr(twin.AttrBold)
lineNumbersStyle = twin.StyleDefault.WithAttr(twin.AttrDim)
if chromaStyle == nil || chromaFormatter == nil {
return
}
headingStyle := twinStyleFromChroma(terminalBackground, chromaStyle, chromaFormatter, chroma.GenericHeading, true)
if headingStyle != nil && !withTerminalFg {
log.Trace("Heading style set from Chroma: ", *headingStyle)
textstyles.ManPageHeading = *headingStyle
}
chromaLineNumbers := twinStyleFromChroma(terminalBackground, chromaStyle, chromaFormatter, chroma.LineNumbers, true)
if chromaLineNumbers != nil && !withTerminalFg {
// NOTE: We used to dim line numbers here, but Johan found them too hard
// to read. If line numbers should look some other way for some Chroma
// style, go fix that in Chroma!
log.Trace("Line numbers style set from Chroma: ", *chromaLineNumbers)
lineNumbersStyle = *chromaLineNumbers
}
plainText := twinStyleFromChroma(terminalBackground, chromaStyle, chromaFormatter, chroma.None, false)
if plainText != nil && !withTerminalFg {
log.Trace("Plain text style set from Chroma: ", *plainText)
plainTextStyle = *plainText
}
if standoutStyle != nil {
log.Trace("Status bar style set from standout style: ", *standoutStyle)
statusbarStyle = *standoutStyle
} else if statusbarOption == STATUSBAR_STYLE_INVERSE {
statusbarStyle = plainTextStyle.WithAttr(twin.AttrReverse)
} else if statusbarOption == STATUSBAR_STYLE_PLAIN {
plain := twinStyleFromChroma(terminalBackground, chromaStyle, chromaFormatter, chroma.None, false)
if plain != nil {
statusbarStyle = *plain
} else {
statusbarStyle = twin.StyleDefault
}
} else if statusbarOption == STATUSBAR_STYLE_BOLD {
bold := twinStyleFromChroma(terminalBackground, chromaStyle, chromaFormatter, chroma.GenericStrong, true)
if bold != nil {
statusbarStyle = *bold
} else {
statusbarStyle = twin.StyleDefault.WithAttr(twin.AttrBold)
}
} else {
panic(fmt.Sprint("Unrecognized status bar style: ", statusbarOption))
}
statusbarFileStyle = statusbarStyle.WithAttr(twin.AttrUnderline)
configureHighlighting(terminalBackground, configureSearchHitLineBackground)
}
// Expects to be called from the end of styleUI(), since at that
// point we should have all data we need to set up highlighting.
func configureHighlighting(terminalBackground *twin.Color, configureSearchHitLineBackground bool) {
if standoutStyle != nil {
searchHitStyle = *standoutStyle
log.Trace("Search hit style set from standout style: ", searchHitStyle)
} else {
log.Trace("Search hit style set to default: ", searchHitStyle)
}
//
// Everything below this point relates to figuring out which background
// color we should use for lines with search hits.
//
if !configureSearchHitLineBackground {
log.Trace("Not configuring search hit line background color")
return
}
var plainBg twin.Color
if terminalBackground != nil {
plainBg = *terminalBackground
} else if plainTextStyle.HasAttr(twin.AttrReverse) {
plainBg = plainTextStyle.Foreground()
} else {
plainBg = plainTextStyle.Background()
}
hitBg := searchHitStyle.Background()
hitFg := searchHitStyle.Foreground()
if searchHitStyle.HasAttr(twin.AttrReverse) {
hitBg = searchHitStyle.Foreground()
hitFg = searchHitStyle.Background()
}
if hitBg == twin.ColorDefault && hitFg != twin.ColorDefault {
// Not knowing the hit background color will be a problem further down
// when we want to create a line background color for lines with search
// hits.
//
// But since we know the foreground color, we can cheat and pretend the
// background is as far away from the foreground as possible.
hitBg = getOppositeColor(hitFg)
}
if hitBg == twin.ColorDefault && terminalBackground != nil {
// Assume the hit background is the opposite of the terminal background
hitBg = getOppositeColor(*terminalBackground)
}
if plainBg != twin.ColorDefault && hitBg != twin.ColorDefault {
// We have two real colors. Mix them! I got to "0.2" by testing some
// numbers. 0.2 is visible but not too strong.
mixed := plainBg.Mix(hitBg, 0.2)
searchHitLineBackground = &mixed
log.Trace("Search hit line background set to mixed color: ", *searchHitLineBackground)
} else {
log.Debug("Cannot set search hit line background based on plainBg=", plainBg, " hitBg=", hitBg)
}
}
func TermcapToStyle(termcap string) (twin.Style, error) {
// Add a character to be sure we have one to take the format from
cells := textstyles.StyledRunesFromString(twin.StyleDefault, termcap+"x", nil, 0).StyledRunes
if len(cells) != 1 {
return twin.StyleDefault, fmt.Errorf("Expected styling only and no text")
}
return cells[len(cells)-1].Style, nil
}
moor-2.10.3/internal/styling_test.go 0000664 0000000 0000000 00000002203 15135744745 0017460 0 ustar 00root root 0000000 0000000 package internal
import (
"os"
"testing"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/styles"
"github.com/walles/moor/v2/twin"
"gotest.tools/v3/assert"
)
func TestTwinStyleFromChroma(t *testing.T) {
// Test that getting exact GenericHeading from base16-snazzy works
style := twinStyleFromChroma(
nil,
styles.Registry["base16-snazzy"],
&formatters.TTY16m,
chroma.GenericHeading,
true,
)
assert.Equal(t,
*style,
twin.StyleDefault.
WithAttr(twin.AttrBold).
WithForeground(twin.NewColor24Bit(0xe2, 0xe4, 0xe5)))
}
func TestSetStyle(t *testing.T) {
assert.NilError(t, os.Setenv("MOOR_TEST_STYLE", "\x1b[1;31m"))
style := twin.StyleDefault
setStyle(&style, "MOOR_TEST_STYLE", nil)
assert.Equal(t, style, twin.StyleDefault.WithAttr(twin.AttrBold).WithForeground(twin.NewColor16(1)))
}
// Regression test for https://github.com/walles/moor/issues/339
//
// We used to crash doing this.
func TestConfigureHighlighting_No24BitColors(t *testing.T) {
searchHitStyle = twin.StyleDefault.WithForeground(twin.NewColor16(3))
configureHighlighting(nil, true)
}
moor-2.10.3/internal/textstyles/ 0000775 0000000 0000000 00000000000 15135744745 0016634 5 ustar 00root root 0000000 0000000 moor-2.10.3/internal/textstyles/ansiTokenizer.go 0000664 0000000 0000000 00000050006 15135744745 0022011 0 ustar 00root root 0000000 0000000 // This package handles styled strings. It can strip styling from strings and it
// can turn a styled string into a series of screen cells. Some global variables
// can be used to configure how various things are rendered.
package textstyles
import (
"fmt"
"slices"
"strconv"
"strings"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/twin"
)
// How do we render unprintable characters?
type UnprintableStyleT int
const (
UnprintableStyleHighlight UnprintableStyleT = iota
UnprintableStyleWhitespace
)
var UnprintableStyle UnprintableStyleT
// These three styles will be configured from styling.go
var ManPageBold = twin.StyleDefault.WithAttr(twin.AttrBold)
var ManPageUnderline = twin.StyleDefault.WithAttr(twin.AttrUnderline)
var ManPageHeading = twin.StyleDefault.WithAttr(twin.AttrBold)
// This is what less (version 581.2 on macOS) defaults to
var TabSize = 8
const BACKSPACE = '\b'
type StyledRunesWithTrailer struct {
StyledRunes []CellWithMetadata
Trailer twin.Style
ContainsSearchHit bool
}
func isPlain(s string) bool {
for i := 0; i < len(s); i++ {
byteAtIndex := s[i]
if byteAtIndex < 32 {
return false
}
if byteAtIndex > 127 {
return false
}
}
return true
}
// lineIndex is only used for error reporting
func StripFormatting(s string, lineIndex linemetadata.Index) string {
if isPlain(s) {
return s
}
stripped := strings.Builder{}
stripped.Grow(len(s)) // This makes BenchmarkStripFormatting 6% faster
runeCount := 0
styledStringsFromString(twin.StyleDefault, s, &lineIndex, 0, func(str string, style twin.Style) {
for _, runeValue := range runesFromStyledString(_StyledString{String: str, Style: style}) {
switch runeValue {
case '\x09': // TAB
for {
stripped.WriteRune(' ')
runeCount++
if runeCount%TabSize == 0 {
// We arrived at the next tab stop
break
}
}
case '�': // Go's broken-UTF8 marker
switch UnprintableStyle {
case UnprintableStyleHighlight:
stripped.WriteRune('?')
case UnprintableStyleWhitespace:
stripped.WriteRune(' ')
default:
panic(fmt.Errorf("Unsupported unprintable-style: %#v", UnprintableStyle))
}
runeCount++
case BACKSPACE:
stripped.WriteRune('<')
runeCount++
default:
if !twin.Printable(runeValue) {
stripped.WriteRune('?')
runeCount++
continue
}
stripped.WriteRune(runeValue)
runeCount++
}
}
})
return stripped.String()
}
// Turn a (formatted) string into a series of screen cells
//
// The prefix will be prepended to the string before parsing. The lineIndex is
// used for error reporting.
//
// maxTokensCount: at most this many tokens will be included in the result. If
// 0, do all runes. For BenchmarkRenderHugeLine() performance.
func StyledRunesFromString(plainTextStyle twin.Style, s string, lineIndex *linemetadata.Index, maxTokensCount int) StyledRunesWithTrailer {
manPageHeading := manPageHeadingFromString(s)
if manPageHeading != nil {
return *manPageHeading
}
cells := make([]CellWithMetadata, 0, len(s))
// Specs: https://en.wikipedia.org/wiki/ANSI_escape_code#3-bit_and_4-bit
styleUnprintable := twin.StyleDefault.WithBackground(twin.NewColor16(1)).WithForeground(twin.NewColor16(7))
trailer := styledStringsFromString(plainTextStyle, s, lineIndex, maxTokensCount, func(str string, style twin.Style) {
for _, token := range tokensFromStyledString(_StyledString{String: str, Style: style}, maxTokensCount) {
switch token.Rune {
case '\x09': // TAB
for {
cells = append(cells, CellWithMetadata{
Rune: ' ',
Style: style,
})
if (len(cells))%TabSize == 0 {
// We arrived at the next tab stop
break
}
}
case '�': // Go's broken-UTF8 marker
switch UnprintableStyle {
case UnprintableStyleHighlight:
cells = append(cells, CellWithMetadata{
Rune: '?',
Style: styleUnprintable,
})
case UnprintableStyleWhitespace:
cells = append(cells, CellWithMetadata{
Rune: '?',
Style: twin.StyleDefault,
})
default:
panic(fmt.Errorf("Unsupported unprintable-style: %#v", UnprintableStyle))
}
case BACKSPACE:
cells = append(cells, CellWithMetadata{
Rune: '<',
Style: styleUnprintable,
})
default:
if !twin.Printable(token.Rune) {
switch UnprintableStyle {
case UnprintableStyleHighlight:
cells = append(cells, CellWithMetadata{
Rune: '?',
Style: styleUnprintable,
})
case UnprintableStyleWhitespace:
cells = append(cells, CellWithMetadata{
Rune: ' ',
Style: twin.StyleDefault,
})
default:
panic(fmt.Errorf("Unsupported unprintable-style: %#v", UnprintableStyle))
}
continue
}
cells = append(cells, CellWithMetadata{
Rune: token.Rune,
Style: token.Style,
})
}
}
})
return StyledRunesWithTrailer{
StyledRunes: cells,
Trailer: trailer,
// Populated in Line.HighlightedTokens(), where the search hit
// highlighting happens
ContainsSearchHit: false,
}
}
// Consume '_ 0 && maxTokensCount < maxBackspaceCheck {
maxBackspaceCheck = maxTokensCount + 20 // Some extra to account for backspaces further out
}
if maxBackspaceCheck > len(styledString.String) {
maxBackspaceCheck = len(styledString.String)
}
if !strings.ContainsRune(styledString.String[:maxBackspaceCheck], BACKSPACE) {
// Shortcut when there's no backspace based formatting to worry about
returnTokensCount := len(styledString.String)
if maxTokensCount > 0 && returnTokensCount > maxTokensCount {
returnTokensCount = maxTokensCount
}
tokens := make([]twin.StyledRune, 0, returnTokensCount)
for _, runeValue := range styledString.String {
if len(tokens) == cap(tokens) {
// We have enough runes, stop here
break
}
tokens = append(tokens, twin.StyledRune{
Rune: runeValue,
Style: styledString.Style,
})
}
return tokens
}
tokens := make([]twin.StyledRune, 0, maxTokensCount)
// Special handling for man page formatted lines. If this is updated you
// must update HasManPageFormatting() as well.
for runes := (lazyRunes{str: styledString.String}); runes.getRelative(0) != nil; runes.next() {
if maxTokensCount > 0 && len(tokens) >= maxTokensCount {
// We have enough runes, stop here
break
}
token := consumeBullet(&runes)
if token != nil {
tokens = append(tokens, *token)
continue
}
token = consumeBoldUnderline(&runes)
if token != nil {
tokens = append(tokens, *token)
continue
}
token = consumeBold(&runes)
if token != nil {
tokens = append(tokens, *token)
continue
}
token = consumeUnderline(&runes)
if token != nil {
tokens = append(tokens, *token)
continue
}
tokens = append(tokens, twin.StyledRune{
Rune: *runes.getRelative(0),
Style: styledString.Style,
})
}
return tokens
}
// Like tokensFromStyledString(), but only checks without building any formatting
func HasManPageFormatting(s string) bool {
for runes := (lazyRunes{str: s}); runes.getRelative(0) != nil; runes.next() {
consumed := consumeBullet(&runes)
if consumed != nil {
return true
}
consumed = consumeBoldUnderline(&runes)
if consumed != nil {
return true
}
consumed = consumeBold(&runes)
if consumed != nil {
return true
}
consumed = consumeUnderline(&runes)
if consumed != nil {
return true
}
}
return false
}
type _StyledString struct {
String string
Style twin.Style
}
// To avoid allocations, our caller is expected to provide us with a
// pre-allocated numbersBuffer for storing the result.
//
// This function is part of the hot code path while searching, so we want it to
// be fast.
//
// # Benchmarking instructions
//
// go test -benchmem -run='^$' -bench=BenchmarkHighlightedSearch . ./...
func splitIntoNumbers(s string, numbersBuffer []uint) ([]uint, error) {
numbers := numbersBuffer[:0]
afterLastSeparator := 0
for i, char := range s {
if char >= '0' && char <= '9' {
continue
}
if char == ';' || char == ':' {
numberString := s[afterLastSeparator:i]
if numberString == "" {
numbers = append(numbers, 0)
continue
}
number, err := strconv.ParseUint(numberString, 10, 64)
if err != nil {
return numbers, err
}
numbers = append(numbers, uint(number))
afterLastSeparator = i + 1
continue
}
return numbers, fmt.Errorf("Unrecognized character in <%s>: %c", s, char)
}
// Now we have to handle the last number
numberString := s[afterLastSeparator:]
if numberString == "" {
numbers = append(numbers, 0)
return numbers, nil
}
number, err := strconv.ParseUint(numberString, 10, 64)
if err != nil {
return numbers, err
}
numbers = append(numbers, uint(number))
return numbers, nil
}
// rawUpdateStyle parses a string of the form "33m" into changes to style. This
// is what comes after ESC[ in an ANSI SGR sequence.
func rawUpdateStyle(style twin.Style, escapeSequenceWithoutHeader string, numbersBuffer []uint) (twin.Style, []uint, error) {
if len(escapeSequenceWithoutHeader) == 0 {
return style, numbersBuffer, fmt.Errorf("empty escape sequence, expected at least an ending letter")
}
if escapeSequenceWithoutHeader[len(escapeSequenceWithoutHeader)-1] != 'm' {
return style, numbersBuffer, fmt.Errorf("escape sequence does not end with 'm': %s", escapeSequenceWithoutHeader)
}
numbersBuffer, err := splitIntoNumbers(escapeSequenceWithoutHeader[:len(escapeSequenceWithoutHeader)-1], numbersBuffer)
if err != nil {
return style, numbersBuffer, fmt.Errorf("splitIntoNumbers: %w", err)
}
index := 0
for index < len(numbersBuffer) {
number := numbersBuffer[index]
index++
switch number {
case 0:
// SGR Reset should not affect the OSC8 hyperlink
style = twin.StyleDefault.WithHyperlink(style.HyperlinkURL())
case 1:
style = style.WithAttr(twin.AttrBold)
case 2:
style = style.WithAttr(twin.AttrDim)
case 3:
style = style.WithAttr(twin.AttrItalic)
case 4:
style = style.WithAttr(twin.AttrUnderline)
case 7:
style = style.WithAttr(twin.AttrReverse)
case 22:
style = style.WithoutAttr(twin.AttrBold).WithoutAttr(twin.AttrDim)
case 23:
style = style.WithoutAttr(twin.AttrItalic)
case 24:
style = style.WithoutAttr(twin.AttrUnderline)
case 27:
style = style.WithoutAttr(twin.AttrReverse)
// Foreground colors, https://pkg.go.dev/github.com/gdamore/tcell#Color
case 30:
style = style.WithForeground(twin.NewColor16(0))
case 31:
style = style.WithForeground(twin.NewColor16(1))
case 32:
style = style.WithForeground(twin.NewColor16(2))
case 33:
style = style.WithForeground(twin.NewColor16(3))
case 34:
style = style.WithForeground(twin.NewColor16(4))
case 35:
style = style.WithForeground(twin.NewColor16(5))
case 36:
style = style.WithForeground(twin.NewColor16(6))
case 37:
style = style.WithForeground(twin.NewColor16(7))
case 38:
var err error
var color *twin.Color
index, color, err = consumeCompositeColor(numbersBuffer, index-1)
if err != nil {
return style, numbersBuffer, fmt.Errorf("Foreground: %w", err)
}
style = style.WithForeground(*color)
case 39:
style = style.WithForeground(twin.ColorDefault)
// Background colors, see https://pkg.go.dev/github.com/gdamore/Color
case 40:
style = style.WithBackground(twin.NewColor16(0))
case 41:
style = style.WithBackground(twin.NewColor16(1))
case 42:
style = style.WithBackground(twin.NewColor16(2))
case 43:
style = style.WithBackground(twin.NewColor16(3))
case 44:
style = style.WithBackground(twin.NewColor16(4))
case 45:
style = style.WithBackground(twin.NewColor16(5))
case 46:
style = style.WithBackground(twin.NewColor16(6))
case 47:
style = style.WithBackground(twin.NewColor16(7))
case 48:
var err error
var color *twin.Color
index, color, err = consumeCompositeColor(numbersBuffer, index-1)
if err != nil {
return style, numbersBuffer, fmt.Errorf("Background: %w", err)
}
style = style.WithBackground(*color)
case 49:
style = style.WithBackground(twin.ColorDefault)
case 58:
var err error
var color *twin.Color
index, color, err = consumeCompositeColor(numbersBuffer, index-1)
if err != nil {
return style, numbersBuffer, fmt.Errorf("Underline: %w", err)
}
style = style.WithUnderlineColor(*color)
case 59:
style = style.WithUnderlineColor(twin.ColorDefault)
// Bright foreground colors: see https://pkg.go.dev/github.com/gdamore/Color
//
// After testing vs less and cat on iTerm2 3.3.9 / macOS Catalina
// 10.15.4 that's how they seem to handle this, tested with:
// * TERM=xterm-256color
// * TERM=screen-256color
case 90:
style = style.WithForeground(twin.NewColor16(8))
case 91:
style = style.WithForeground(twin.NewColor16(9))
case 92:
style = style.WithForeground(twin.NewColor16(10))
case 93:
style = style.WithForeground(twin.NewColor16(11))
case 94:
style = style.WithForeground(twin.NewColor16(12))
case 95:
style = style.WithForeground(twin.NewColor16(13))
case 96:
style = style.WithForeground(twin.NewColor16(14))
case 97:
style = style.WithForeground(twin.NewColor16(15))
case 100:
style = style.WithBackground(twin.NewColor16(8))
case 101:
style = style.WithBackground(twin.NewColor16(9))
case 102:
style = style.WithBackground(twin.NewColor16(10))
case 103:
style = style.WithBackground(twin.NewColor16(11))
case 104:
style = style.WithBackground(twin.NewColor16(12))
case 105:
style = style.WithBackground(twin.NewColor16(13))
case 106:
style = style.WithBackground(twin.NewColor16(14))
case 107:
style = style.WithBackground(twin.NewColor16(15))
default:
return style, numbersBuffer, fmt.Errorf("Unrecognized ANSI SGR code <%d>", number)
}
}
return style, numbersBuffer, nil
}
func joinUints(ints []uint) string {
joinedWithBrackets := strings.ReplaceAll(fmt.Sprint(ints), " ", ";")
joined := joinedWithBrackets[1 : len(joinedWithBrackets)-1]
return joined
}
// numbers is a list of numbers from a ANSI SGR string
// index points to either 38 or 48 in that string
//
// This method will return:
// - The first index in the string that this function did not consume
// - A color value that can be applied to a style
func consumeCompositeColor(numbers []uint, index int) (int, *twin.Color, error) {
baseIndex := index
if numbers[index] != 38 && numbers[index] != 48 && numbers[index] != 58 {
err := fmt.Errorf(
"unknown start of color sequence <%d>, expected 38 (foreground), 48 (background) or 58 (underline): ",
numbers[index],
joinUints(numbers[baseIndex:]))
return -1, nil, err
}
index++
if index >= len(numbers) {
err := fmt.Errorf(
"incomplete color sequence: ",
joinUints(numbers[baseIndex:]))
return -1, nil, err
}
if numbers[index] == 5 {
// Handle 8 bit color
index++
if index >= len(numbers) {
err := fmt.Errorf(
"incomplete 8 bit color sequence: ",
joinUints(numbers[baseIndex:]))
return -1, nil, err
}
colorNumber := numbers[index]
colorValue := twin.NewColor256(uint8(colorNumber))
return index + 1, &colorValue, nil
}
if numbers[index] == 2 {
// Handle 24 bit color
rIndex := index + 1
gIndex := index + 2
bIndex := index + 3
if bIndex >= len(numbers) {
err := fmt.Errorf(
"incomplete 24 bit color sequence, expected N8;2;R;G;Bm: ",
joinUints(numbers[baseIndex:]))
return -1, nil, err
}
rValue := uint8(numbers[rIndex])
gValue := uint8(numbers[gIndex])
bValue := uint8(numbers[bIndex])
colorValue := twin.NewColor24Bit(rValue, gValue, bValue)
return bIndex + 1, &colorValue, nil
}
err := fmt.Errorf(
"unknown color type <%d>, expected 5 (8 bit color) or 2 (24 bit color): ",
numbers[index],
joinUints(numbers[baseIndex:]))
return -1, nil, err
}
moor-2.10.3/internal/textstyles/ansiTokenizer_test.go 0000664 0000000 0000000 00000033436 15135744745 0023060 0 ustar 00root root 0000000 0000000 package textstyles
import (
"bufio"
"fmt"
"os"
"path"
"strings"
"testing"
"unicode/utf8"
"github.com/google/go-cmp/cmp"
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/twin"
"gotest.tools/v3/assert"
)
const samplesDir = "../../sample-files"
// Convert a cells array to a plain string
func cellsToPlainString(cells []CellWithMetadata) string {
returnMe := ""
for _, cell := range cells {
returnMe += string(cell.Rune)
}
return returnMe
}
func getTestFiles(t *testing.T) []string {
files, err := os.ReadDir(samplesDir)
assert.NilError(t, err)
var filenames []string
for _, file := range files {
filenames = append(filenames, path.Join(samplesDir, file.Name()))
}
return filenames
}
// Verify that we can tokenize all lines in ../sample-files/*
// without logging any errors
func TestTokenize(t *testing.T) {
for _, fileName := range getTestFiles(t) {
t.Run(fileName, func(t *testing.T) {
file, err := os.Open(fileName)
if err != nil {
t.Errorf("Error opening file <%s>: %s", fileName, err.Error())
return
}
defer func() {
if err := file.Close(); err != nil {
panic(err)
}
}()
fileReader, err := os.Open(fileName)
assert.NilError(t, err)
fileScanner := bufio.NewScanner(fileReader)
// Upping the buffer like this (from a default of 64kb) makes the
// tests go faster
fileScanner.Buffer(make([]byte, 1024*1024), 1024*1024)
var lineIndex *linemetadata.Index
for fileScanner.Scan() {
line := fileScanner.Text()
if lineIndex == nil {
lineIndex = &linemetadata.Index{}
} else {
next := lineIndex.NonWrappingAdd(1)
lineIndex = &next
}
var loglines strings.Builder
log.SetOutput(&loglines)
tokens := StyledRunesFromString(twin.StyleDefault, line, lineIndex, 0).StyledRunes
plainString := StripFormatting(line, *lineIndex)
if len(tokens) != utf8.RuneCountInString(plainString) {
t.Errorf("%s:%s: len(tokens)=%d, len(plainString)=%d for: <%s>",
fileName, lineIndex.Format(),
len(tokens), utf8.RuneCountInString(plainString), line)
continue
}
// Tokens and plain have the same lengths, compare contents
plainStringChars := []rune(plainString)
for index, plainChar := range plainStringChars {
cellChar := tokens[index]
if cellChar.Rune == plainChar {
continue
}
if cellChar.Rune == '•' && plainChar == 'o' {
// Pretty bullets on man pages
continue
}
// Chars mismatch!
plainStringFromCells := cellsToPlainString(tokens)
positionMarker := strings.Repeat(" ", index) + "^"
cellCharString := string(cellChar.Rune)
if !twin.Printable(cellChar.Rune) {
cellCharString = fmt.Sprint(int(cellChar.Rune))
}
plainCharString := string(plainChar)
if !twin.Printable(plainChar) {
plainCharString = fmt.Sprint(int(plainChar))
}
t.Errorf("%s:%s, 0-based column %d: cell char <%s> != plain char <%s>:\nPlain: %s\nCells: %s\n %s",
fileName, lineIndex.Format(), index,
cellCharString, plainCharString,
plainString,
plainStringFromCells,
positionMarker,
)
break
}
if len(loglines.String()) != 0 {
t.Errorf("%s: %s", fileName, loglines.String())
continue
}
}
})
}
}
func TestUnderline(t *testing.T) {
tokens := StyledRunesFromString(twin.StyleDefault, "a\x1b[4mb\x1b[24mc", nil, 0).StyledRunes
assert.Equal(t, len(tokens), 3)
assert.Equal(t, tokens[0], CellWithMetadata{Rune: 'a', Style: twin.StyleDefault})
assert.Equal(t, tokens[1], CellWithMetadata{Rune: 'b', Style: twin.StyleDefault.WithAttr(twin.AttrUnderline)})
assert.Equal(t, tokens[2], CellWithMetadata{Rune: 'c', Style: twin.StyleDefault})
}
func TestManPages(t *testing.T) {
// Bold
tokens := StyledRunesFromString(twin.StyleDefault, "ab\bbc", nil, 0).StyledRunes
assert.Equal(t, len(tokens), 3)
assert.Equal(t, tokens[0], CellWithMetadata{Rune: 'a', Style: twin.StyleDefault})
assert.Equal(t, tokens[1], CellWithMetadata{Rune: 'b', Style: twin.StyleDefault.WithAttr(twin.AttrBold)})
assert.Equal(t, tokens[2], CellWithMetadata{Rune: 'c', Style: twin.StyleDefault})
// Underline
tokens = StyledRunesFromString(twin.StyleDefault, "a_\bbc", nil, 0).StyledRunes
assert.Equal(t, len(tokens), 3)
assert.Equal(t, tokens[0], CellWithMetadata{Rune: 'a', Style: twin.StyleDefault})
assert.Equal(t, tokens[1], CellWithMetadata{Rune: 'b', Style: twin.StyleDefault.WithAttr(twin.AttrUnderline)})
assert.Equal(t, tokens[2], CellWithMetadata{Rune: 'c', Style: twin.StyleDefault})
// Bullet point 1, taken from doing this on my macOS system:
// env PAGER="hexdump -C" man printf | moor
tokens = StyledRunesFromString(twin.StyleDefault, "a+\b+\bo\bob", nil, 0).StyledRunes
assert.Equal(t, len(tokens), 3)
assert.Equal(t, tokens[0], CellWithMetadata{Rune: 'a', Style: twin.StyleDefault})
assert.Equal(t, tokens[1], CellWithMetadata{Rune: '•', Style: twin.StyleDefault})
assert.Equal(t, tokens[2], CellWithMetadata{Rune: 'b', Style: twin.StyleDefault})
// Bullet point 2, taken from doing this using the "fish" shell on my macOS system:
// man printf | hexdump -C | moor
tokens = StyledRunesFromString(twin.StyleDefault, "a+\bob", nil, 0).StyledRunes
assert.Equal(t, len(tokens), 3)
assert.Equal(t, tokens[0], CellWithMetadata{Rune: 'a', Style: twin.StyleDefault})
assert.Equal(t, tokens[1], CellWithMetadata{Rune: '•', Style: twin.StyleDefault})
assert.Equal(t, tokens[2], CellWithMetadata{Rune: 'b', Style: twin.StyleDefault})
}
func TestManPageHeadings(t *testing.T) {
// Set a marker style we can recognize and test for
ManPageHeading = twin.StyleDefault.WithForeground(twin.NewColor16(2))
manPageHeading := ""
for _, char := range "JOHAN HELLO" {
manPageHeading += string(char) + "\b" + string(char)
}
notAllCaps := ""
for _, char := range "Johan Hello" {
notAllCaps += string(char) + "\b" + string(char)
}
// A line with only man page bold caps should be considered a heading
for _, token := range StyledRunesFromString(twin.StyleDefault, manPageHeading, nil, 0).StyledRunes {
assert.Equal(t, token.Style, ManPageHeading)
}
// A line with only non-man-page bold caps should not be considered a heading
wrongKindOfBold := "\x1b[1mJOHAN HELLO"
for _, token := range StyledRunesFromString(twin.StyleDefault, wrongKindOfBold, nil, 0).StyledRunes {
assert.Equal(t, token.Style, twin.StyleDefault.WithAttr(twin.AttrBold))
}
// A line with not all caps should not be considered a heading
for _, token := range StyledRunesFromString(twin.StyleDefault, notAllCaps, nil, 0).StyledRunes {
assert.Equal(t, token.Style, twin.StyleDefault.WithAttr(twin.AttrBold))
}
}
func TestConsumeCompositeColorHappy(t *testing.T) {
// 8 bit color
// Example from: https://github.com/walles/moor/issues/14
newIndex, color, err := consumeCompositeColor([]uint{38, 5, 74}, 0)
assert.NilError(t, err)
assert.Equal(t, newIndex, 3)
assert.Equal(t, *color, twin.NewColor256(74))
// 24 bit color
newIndex, color, err = consumeCompositeColor([]uint{38, 2, 10, 20, 30}, 0)
assert.NilError(t, err)
assert.Equal(t, newIndex, 5)
assert.Equal(t, *color, twin.NewColor24Bit(10, 20, 30))
}
func TestConsumeCompositeColorBadPrefix(t *testing.T) {
// 8 bit color
// Example from: https://github.com/walles/moor/issues/14
_, color, err := consumeCompositeColor([]uint{29}, 0)
assert.Equal(t, err.Error(), "unknown start of color sequence <29>, expected 38 (foreground), 48 (background) or 58 (underline): ")
assert.Assert(t, color == nil)
}
func TestConsumeCompositeColorBadType(t *testing.T) {
_, color, err := consumeCompositeColor([]uint{38, 4}, 0)
// https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
assert.Equal(t, err.Error(), "unknown color type <4>, expected 5 (8 bit color) or 2 (24 bit color): ")
assert.Assert(t, color == nil)
}
func TestConsumeCompositeColorIncomplete(t *testing.T) {
_, color, err := consumeCompositeColor([]uint{38}, 0)
assert.Equal(t, err.Error(), "incomplete color sequence: ")
assert.Assert(t, color == nil)
}
func TestConsumeCompositeColorIncomplete8Bit(t *testing.T) {
_, color, err := consumeCompositeColor([]uint{38, 5}, 0)
assert.Equal(t, err.Error(), "incomplete 8 bit color sequence: ")
assert.Assert(t, color == nil)
}
func TestConsumeCompositeColorIncomplete24Bit(t *testing.T) {
_, color, err := consumeCompositeColor([]uint{38, 2, 10, 20}, 0)
assert.Equal(t, err.Error(), "incomplete 24 bit color sequence, expected N8;2;R;G;Bm: ")
assert.Assert(t, color == nil)
}
func TestRawUpdateStyle(t *testing.T) {
numberColored, _, err := rawUpdateStyle(twin.StyleDefault, "33m", make([]uint, 0))
assert.NilError(t, err)
assert.Equal(t, numberColored, twin.StyleDefault.WithForeground(twin.NewColor16(3)))
}
// Test with the recommended terminator ESC-backslash.
//
// Ref: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda#the-escape-sequence
func TestHyperlink_escBackslash(t *testing.T) {
url := "http://example.com"
tokens := StyledRunesFromString(twin.StyleDefault, "a\x1b]8;;"+url+"\x1b\\bc\x1b]8;;\x1b\\d", nil, 0).StyledRunes
assert.DeepEqual(t, tokens, []CellWithMetadata{
{Rune: 'a', Style: twin.StyleDefault},
{Rune: 'b', Style: twin.StyleDefault.WithHyperlink(&url)},
{Rune: 'c', Style: twin.StyleDefault.WithHyperlink(&url)},
{Rune: 'd', Style: twin.StyleDefault},
},
cmp.Comparer(func(a, b CellWithMetadata) bool { return a.Equal(b) }))
}
// Test with the not-recommended terminator BELL (ASCII 7).
//
// Ref: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda#the-escape-sequence
func TestHyperlink_bell(t *testing.T) {
url := "http://example.com"
tokens := StyledRunesFromString(twin.StyleDefault, "a\x1b]8;;"+url+"\x07bc\x1b]8;;\x07d", nil, 0).StyledRunes
assert.DeepEqual(t, tokens, []CellWithMetadata{
{Rune: 'a', Style: twin.StyleDefault},
{Rune: 'b', Style: twin.StyleDefault.WithHyperlink(&url)},
{Rune: 'c', Style: twin.StyleDefault.WithHyperlink(&url)},
{Rune: 'd', Style: twin.StyleDefault},
},
cmp.Comparer(func(a, b CellWithMetadata) bool { return a.Equal(b) }))
}
// Test with some other ESC sequence than ESC-backslash
func TestHyperlink_nonTerminatingEsc(t *testing.T) {
complete := "a\x1b]8;;https://example.com\x1bbc"
tokens := StyledRunesFromString(twin.StyleDefault, complete, nil, 0).StyledRunes
// This should not be treated as any link
for i := 0; i < len(complete); i++ {
if complete[i] == '\x1b' {
// These get special rendering, if everything else matches that's
// good enough.
continue
}
assert.Equal(t, tokens[i], CellWithMetadata{Rune: rune(complete[i]), Style: twin.StyleDefault},
"i=%d, c=%s, tokens=%v", i, string(complete[i]), tokens)
}
}
func TestHyperlink_incomplete(t *testing.T) {
complete := "a\x1b]8;;X\x1b\\"
for l := len(complete) - 1; l >= 0; l-- {
incomplete := complete[:l]
t.Run(fmt.Sprintf("l=%d incomplete=<%s>", l, strings.ReplaceAll(incomplete, "\x1b", "ESC")), func(t *testing.T) {
tokens := StyledRunesFromString(twin.StyleDefault, incomplete, nil, 0).StyledRunes
for i := 0; i < l; i++ {
if complete[i] == '\x1b' {
// These get special rendering, if everything else matches
// that's good enough.
continue
}
assert.Equal(t, tokens[i], CellWithMetadata{Rune: rune(complete[i]), Style: twin.StyleDefault})
}
})
}
}
func TestRawUpdateStyleResetDoesNotAffectHyperlink(t *testing.T) {
url := "file:///Users/johan/src/riff/src/refiner.rs"
styleWithLink := twin.StyleDefault.WithHyperlink(&url)
// ESC[m should reset style, but not touch the hyperlink
updated, _, err := rawUpdateStyle(styleWithLink, "m", nil)
assert.NilError(t, err)
assert.Assert(t, updated.HyperlinkURL() != nil)
assert.Equal(t, *updated.HyperlinkURL(), url)
}
// Ref: https://github.com/walles/moor/issues/372
func TestIssue372(t *testing.T) {
const maxTokensCount = 10
// Load test data once
data, err := os.ReadFile(path.Join(samplesDir, "issue-372.txt"))
assert.NilError(t, err)
// Expect one newline terminated line
lines := strings.Split(string(data), "\n")
assert.Equal(t, 2, len(lines))
assert.Equal(t, 0, len(lines[1]))
styled := StyledRunesFromString(twin.StyleDefault, lines[0], nil, maxTokensCount).StyledRunes
assert.Equal(t, len(styled), maxTokensCount)
}
// Benchmark stripping formatting from a colored git diff sample.
// To run:
//
// go test -bench=BenchmarkStripFormatting -benchmem ./...
func BenchmarkStripFormatting(b *testing.B) {
// Load sample input once
data, err := os.ReadFile(path.Join(samplesDir, "gitdiff-color.txt"))
if err != nil {
b.Fatalf("read sample: %v", err)
}
lines := strings.Split(string(data), "\n")
// Set processed bytes per iteration
b.SetBytes(int64(len(data)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, line := range lines {
// We ignore the output; benchmarking execution time only
_ = StripFormatting(line, linemetadata.Index{})
}
}
}
// Benchmark stripping formatting from a colored git diff sample.
// To run:
//
// go test -bench=BenchmarkStripFormattingUnformattedInput -benchmem ./...
func BenchmarkStripFormattingUnformattedInput(b *testing.B) {
// Load sample input once
data, err := os.ReadFile(path.Join(samplesDir, "gitdiff-color.txt"))
if err != nil {
b.Fatalf("read sample: %v", err)
}
// Remove formatting before benchmarking
var unformatted strings.Builder
formattedLines := strings.Split(string(data), "\n")
for _, line := range formattedLines {
unformatted.WriteString(StripFormatting(line, linemetadata.Index{}))
unformatted.WriteString("\n")
}
lines := strings.Split(unformatted.String(), "\n")
// Set processed bytes per iteration
b.SetBytes(int64(len(unformatted.String())))
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, line := range lines {
// We ignore the output; benchmarking execution time only
_ = StripFormatting(line, linemetadata.Index{})
}
}
}
moor-2.10.3/internal/textstyles/cellWithMetadata.go 0000664 0000000 0000000 00000004074 15135744745 0022404 0 ustar 00root root 0000000 0000000 package textstyles
import (
"unicode"
"github.com/walles/moor/v2/twin"
)
// Like twin.StyledRune, but with additional metadata
type CellWithMetadata struct {
Rune rune
Style twin.Style
cachedWidth *int
StartsSearchHit bool // True if this cell is the start of a search hit
IsSearchHit bool // True if this cell is part of a search hit
}
// Required for some tests to pass
func (r CellWithMetadata) Equal(b CellWithMetadata) bool {
if r.Rune != b.Rune {
return false
}
if !r.Style.Equal(b.Style) {
return false
}
if r.IsSearchHit != b.IsSearchHit {
return false
}
if r.StartsSearchHit != b.StartsSearchHit {
return false
}
return true
}
func (r CellWithMetadata) ToStyledRune() twin.StyledRune {
return twin.NewStyledRune(r.Rune, r.Style)
}
func (r *CellWithMetadata) Width() int {
if r.cachedWidth != nil {
return *r.cachedWidth
}
// Cache it
w := r.ToStyledRune().Width()
r.cachedWidth = &w
return w
}
type CellWithMetadataSlice []CellWithMetadata
func (runes CellWithMetadataSlice) Equal(other CellWithMetadataSlice) bool {
if len(runes) != len(other) {
return false
}
for i := range runes {
if !runes[i].Equal(other[i]) {
return false
}
}
return true
}
// Returns a copy of the slice with leading whitespace removed
func (runes CellWithMetadataSlice) WithoutSpaceLeft() CellWithMetadataSlice {
for i := range runes {
cell := runes[i]
if !unicode.IsSpace(cell.Rune) {
return runes[i:]
}
// That was a space, keep looking
}
// All whitespace, return empty
return CellWithMetadataSlice{}
}
// Returns a copy of the slice with trailing whitespace removed
func (runes CellWithMetadataSlice) WithoutSpaceRight() CellWithMetadataSlice {
for i := len(runes) - 1; i >= 0; i-- {
cell := runes[i]
if !unicode.IsSpace(cell.Rune) {
return runes[0 : i+1]
}
// That was a space, keep looking
}
// All whitespace, return empty
return CellWithMetadataSlice{}
}
func (runes CellWithMetadataSlice) ContainsSearchHit() bool {
for _, cell := range runes {
if cell.IsSearchHit {
return true
}
}
return false
}
moor-2.10.3/internal/textstyles/lazyrunes.go 0000664 0000000 0000000 00000002333 15135744745 0021220 0 ustar 00root root 0000000 0000000 package textstyles
import "unicode/utf8"
// Lazily iterate a string by runes. Init by setting str only.
//
// Use getRelative(0) to get the current rune, and do next() to advance to the
// next rune.
type lazyRunes struct {
str string
// From what position in the string should we pick the next rune?
nextByteIndex int
lookaheadBuffer []rune
}
// Get rune at index runeIndex relative to the current rune
func (l *lazyRunes) getRelative(runeIndex int) *rune {
for runeIndex >= len(l.lookaheadBuffer) {
// Need to add one more rune to the lookahead buffer
if l.nextByteIndex >= len(l.str) {
// No more runes
return nil
}
newRune, runeSize := utf8.DecodeRuneInString(l.str[l.nextByteIndex:])
if runeSize == 0 {
panic("We just checked, there should be more runes")
}
// Append the new rune to the lookahead buffer
l.lookaheadBuffer = append(l.lookaheadBuffer, newRune)
l.nextByteIndex += runeSize
}
return &l.lookaheadBuffer[runeIndex]
}
// Move the base rune index forward by one rune.
func (l *lazyRunes) next() {
if l.getRelative(0) == nil {
// Already past the end
return
}
// Shift the lookahead buffer down
if len(l.lookaheadBuffer) > 0 {
l.lookaheadBuffer = l.lookaheadBuffer[1:]
}
}
moor-2.10.3/internal/textstyles/lazyrunes_test.go 0000664 0000000 0000000 00000001374 15135744745 0022263 0 ustar 00root root 0000000 0000000 package textstyles
import (
"testing"
"gotest.tools/v3/assert"
)
func TestLazyRunes_empty(t *testing.T) {
testMe := lazyRunes{str: ""}
assert.Equal(t, true, testMe.getRelative(0) == nil)
}
func TestLazyRunes_unicode(t *testing.T) {
testMe := lazyRunes{str: "åäö"}
// What's up first?
assert.Equal(t, 'Ã¥', *testMe.getRelative(0))
assert.Equal(t, 'ä', *testMe.getRelative(1))
// Intentionally don't get the third rune yet
// Move to 'ä'
testMe.next()
assert.Equal(t, 'ä', *testMe.getRelative(0))
// Move to 'ö'
testMe.next()
assert.Equal(t, 'ö', *testMe.getRelative(0))
// No more runes
assert.Equal(t, true, testMe.getRelative(1) == nil)
// Move past the end
testMe.next()
assert.Equal(t, true, testMe.getRelative(0) == nil)
}
moor-2.10.3/internal/textstyles/manPageHeading.go 0000664 0000000 0000000 00000005362 15135744745 0022021 0 ustar 00root root 0000000 0000000 package textstyles
import (
"unicode"
"github.com/walles/moor/v2/twin"
)
func manPageHeadingFromString(s string) *StyledRunesWithTrailer {
// For great performance, first check the string without allocating any
// memory.
if !parseManPageHeading(s, func(_ CellWithMetadata) {}) {
return nil
}
cells := make([]CellWithMetadata, 0, len(s)/2)
ok := parseManPageHeading(s, func(cell CellWithMetadata) {
cells = append(cells, cell)
})
if !ok {
panic("man page heading state changed")
}
return &StyledRunesWithTrailer{
StyledRunes: cells,
Trailer: twin.StyleDefault,
}
}
// Reports back one cell at a time. Returns true if the entire string was a man
// page heading.
//
// If it was not, false will be returned and the cell reporting will be
// interrupted.
//
// A man page heading is all caps. Also, each character is encoded as
// char+backspace+char, where both chars need to be the same. Whitespace is an
// exception, they can be not bold.
func parseManPageHeading(s string, reportStyledRune func(CellWithMetadata)) bool {
if len(s) < 3 {
// We don't want to match empty strings. Also, strings of length 1 and 2
// cannot be man page headings since "char+backspace+char" is 3 bytes.
return false
}
type stateT int
const (
stateExpectingFirstChar stateT = iota
stateExpectingBackspace
stateExpectingSecondChar
)
state := stateExpectingFirstChar
var firstChar rune
lapCounter := -1
for _, char := range s {
lapCounter++
switch state {
case stateExpectingFirstChar:
if lapCounter == 0 && unicode.IsSpace(char) {
// Headings do not start with whitespace
return false
}
if char == '\b' {
// Starting with backspace is an error
return false
}
firstChar = char
state = stateExpectingBackspace
case stateExpectingBackspace:
if char == '\b' {
state = stateExpectingSecondChar
continue
}
if unicode.IsSpace(firstChar) {
// Whitespace is an exception, it can be not bold
reportStyledRune(CellWithMetadata{Rune: firstChar, Style: ManPageHeading})
// Assume what we got was a new first char
firstChar = char
state = stateExpectingBackspace
continue
}
// No backspace and no previous-was-whitespace, this is an error
return false
case stateExpectingSecondChar:
if char == '\b' {
// Ending with backspace is an error
return false
}
if char != firstChar {
// Different first and last char is an error
return false
}
if unicode.IsLetter(char) && !unicode.IsUpper(char) {
// Not ALL CAPS => Not a heading
return false
}
reportStyledRune(CellWithMetadata{Rune: char, Style: ManPageHeading})
state = stateExpectingFirstChar
default:
panic("Unknown state")
}
}
return state == stateExpectingFirstChar
}
moor-2.10.3/internal/textstyles/manPageHeading_test.go 0000664 0000000 0000000 00000003761 15135744745 0023061 0 ustar 00root root 0000000 0000000 package textstyles
import (
"testing"
"github.com/walles/moor/v2/twin"
"gotest.tools/v3/assert"
)
func isManPageHeading(s string) bool {
return parseManPageHeading(s, func(_ CellWithMetadata) {})
}
func TestIsManPageHeading(t *testing.T) {
assert.Assert(t, !isManPageHeading(""))
assert.Assert(t, !isManPageHeading("A"), "Incomplete sequence")
assert.Assert(t, !isManPageHeading("A\b"), "Incomplete sequence")
assert.Assert(t, isManPageHeading("A\bA"))
assert.Assert(t, isManPageHeading("A\bA B\bB"), "Whitespace can be not-bold")
assert.Assert(t, !isManPageHeading("A\bC"), "Different first and last char")
assert.Assert(t, !isManPageHeading("a\ba"), "Not ALL CAPS")
assert.Assert(t, !isManPageHeading("A\bAX"), "Incomplete sequence")
assert.Assert(t, !isManPageHeading(" \b "), "Headings do not start with space")
}
func TestManPageHeadingFromString_NotBoldSpace(t *testing.T) {
// Set a marker style we can recognize and test for
ManPageHeading = twin.StyleDefault.WithForeground(twin.NewColor16(2))
result := manPageHeadingFromString("A\bA B\bB")
assert.Assert(t, result != nil)
assert.Equal(t, len(result.StyledRunes), 3)
assert.Equal(t, result.StyledRunes[0], CellWithMetadata{Rune: 'A', Style: ManPageHeading})
assert.Equal(t, result.StyledRunes[1], CellWithMetadata{Rune: ' ', Style: ManPageHeading})
assert.Equal(t, result.StyledRunes[2], CellWithMetadata{Rune: 'B', Style: ManPageHeading})
}
func TestManPageHeadingFromString_WithBoldSpace(t *testing.T) {
// Set a marker style we can recognize and test for
ManPageHeading = twin.StyleDefault.WithForeground(twin.NewColor16(2))
result := manPageHeadingFromString("A\bA \b B\bB")
assert.Assert(t, result != nil)
assert.Equal(t, len(result.StyledRunes), 3)
assert.Equal(t, result.StyledRunes[0], CellWithMetadata{Rune: 'A', Style: ManPageHeading})
assert.Equal(t, result.StyledRunes[1], CellWithMetadata{Rune: ' ', Style: ManPageHeading})
assert.Equal(t, result.StyledRunes[2], CellWithMetadata{Rune: 'B', Style: ManPageHeading})
}
moor-2.10.3/internal/textstyles/styledStringSplitter.go 0000664 0000000 0000000 00000023776 15135744745 0023424 0 ustar 00root root 0000000 0000000 package textstyles
import (
"fmt"
"strings"
"unicode/utf8"
log "github.com/sirupsen/logrus"
"github.com/walles/moor/v2/internal/linemetadata"
"github.com/walles/moor/v2/twin"
)
const esc = '\x1b'
type styledStringSplitter struct {
input string
lineIndex *linemetadata.Index // Used for error reporting
plainTextStyle twin.Style
maxTokensCount int
reportedRunesCount int
nextByteIndex int
previousByteIndex int
inProgressString strings.Builder
inProgressStyle twin.Style
numbersBuffer []uint
trailer twin.Style
callback func(str string, style twin.Style)
}
// Returns the style of the line's trailer.
//
// The lineIndex is only used for error reporting.
//
// maxTokensCount: at most this many tokens will be included in the result. If
// 0, do all runes. For BenchmarkRenderHugeLine() performance.
func styledStringsFromString(plainTextStyle twin.Style, s string, lineIndex *linemetadata.Index, maxTokensCount int, callback func(string, twin.Style)) twin.Style {
if !strings.ContainsAny(s, "\x1b") {
// This shortcut makes BenchmarkPlainTextSearch() perform a lot better
callback(s, plainTextStyle)
return plainTextStyle
}
splitter := styledStringSplitter{
input: s,
lineIndex: lineIndex,
plainTextStyle: plainTextStyle, // How plain text should be styled
maxTokensCount: maxTokensCount,
inProgressStyle: plainTextStyle, // Plain text style until something else comes along
callback: callback,
trailer: plainTextStyle, // Plain text style until something else comes along
}
splitter.run()
return splitter.trailer
}
func (s *styledStringSplitter) nextChar() rune {
if s.nextByteIndex >= len(s.input) {
s.previousByteIndex = s.nextByteIndex
return -1
}
char, size := utf8.DecodeRuneInString(s.input[s.nextByteIndex:])
s.previousByteIndex = s.nextByteIndex
s.nextByteIndex += size
return char
}
// Returns whatever the last call to nextChar() returned
func (s *styledStringSplitter) lastChar() rune {
if s.previousByteIndex >= len(s.input) {
return -1
}
char, _ := utf8.DecodeRuneInString(s.input[s.previousByteIndex:])
return char
}
func (s *styledStringSplitter) run() {
char := s.nextChar()
for {
if char == -1 {
s.finalizeCurrentPart()
return
}
if char == esc {
escIndex := s.previousByteIndex
err := s.handleEscape()
if err != nil {
header := ""
if s.lineIndex != nil {
header = fmt.Sprintf("Line %s: ", s.lineIndex.Format())
}
failed := s.input[escIndex:s.nextByteIndex]
log.Debug(header, "<", strings.ReplaceAll(failed, "\x1b", "ESC"), ">: ", err)
// Somewhere in handleEscape(), we got a character that was
// unexpected. We need to treat everything up to before that
// character as just plain runes.
for _, char := range s.input[escIndex:s.previousByteIndex] {
s.handleRune(char)
}
// Start over with the character that caused the problem
char = s.lastChar()
continue
}
} else {
s.handleRune(char)
}
char = s.nextChar()
}
}
func (s *styledStringSplitter) handleRune(char rune) {
s.inProgressString.WriteRune(char)
}
func (s *styledStringSplitter) handleEscape() error {
char := s.nextChar()
if char == '[' {
// Got the start of a CSI sequence
return s.consumeControlSequence()
}
if char == ']' {
// Got the start of an OSC sequence
return s.consumeOsc()
}
if char == '(' {
// Designate G0 charset: https://www.xfree86.org/4.8.0/ctlseqs.html
return s.consumeG0Charset()
}
return fmt.Errorf("Unhandled Fe sequence ESC%c", char)
}
// Consume a control sequence up until it ends
func (s *styledStringSplitter) consumeControlSequence() error {
// Points to right after "ESC["
startIndex := s.nextByteIndex
// We're looking for a letter to end the CSI sequence
for {
char := s.nextChar()
if char == -1 {
return fmt.Errorf("Line ended in the middle of a control sequence")
}
// Range from here:
// https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences
if char >= 0x30 && char <= 0x3f {
// Sequence still in progress
continue
}
// The end, handle what we got
endIndexExclusive := s.nextByteIndex
return s.handleCompleteControlSequence(s.input[startIndex:endIndexExclusive])
}
}
func (s *styledStringSplitter) consumeG0Charset() error {
// First char after "ESC("
char := s.nextChar()
if char == 'B' {
// G0 charset is now "B" (ASCII)
s.startNewPart(s.plainTextStyle)
return nil
}
return fmt.Errorf("Unhandled G0 charset: %c", char)
}
// If the whole CSI sequence is ESC[33m, you should call this function with just
// "33m".
func (s *styledStringSplitter) handleCompleteControlSequence(sequence string) error {
if sequence == "K" || sequence == "0K" {
// Clear to end of line
s.trailer = s.inProgressStyle
return nil
}
lastChar := sequence[len(sequence)-1]
if lastChar == 'm' {
var newStyle twin.Style
var err error
newStyle, s.numbersBuffer, err = rawUpdateStyle(s.inProgressStyle, sequence, s.numbersBuffer)
if err != nil {
return err
}
s.startNewPart(newStyle)
return nil
}
if lastChar == 'n' {
// Device status report, expects us to respond, just ignore them.
//
// Ref: https://vt100.net/docs/vt510-rm/DSR.html
return nil
}
return fmt.Errorf("Unhandled CSI type %q", lastChar)
}
// Consume an OSC sequence up until it ends
func (s *styledStringSplitter) consumeOsc() error {
// Points to right after "ESC]"
startIndex := s.nextByteIndex
// We're looking for a letter to end the CSI sequence
for {
char := s.nextChar()
if char == -1 {
return fmt.Errorf("Line ended in the middle of an OSC sequence")
}
if char == '\a' {
// Got the end of the OSC sequence
return s.handleOsc(s.input[startIndex:s.previousByteIndex])
}
if char == esc {
escIndex := s.previousByteIndex
afterEsc := s.nextChar()
if afterEsc == '\\' {
// Got the end of the OSC sequence
return s.handleOsc(s.input[startIndex:escIndex])
}
if afterEsc == -1 {
return fmt.Errorf("Line ended while ending an OSC sequence")
}
return fmt.Errorf("Expected OSC sequence to end with BEL or ESC \\ but got ESC %q", afterEsc)
}
if s.input[startIndex:s.nextByteIndex] == "8;;" {
// Special case, here comes an URL
return s.handleURL()
}
}
}
// Expects an OSC sequence as argument. The terminator is not included, what we
// get here is just the payload.
func (s *styledStringSplitter) handleOsc(sequence string) error {
if strings.HasPrefix(sequence, "133;") && len(sequence) == len("133;A") {
// Got ESC]133;X, where "X" could be anything. These are prompt hints,
// and rendering those makes no sense. We should just ignore them:
// https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md
return nil
}
if strings.HasSuffix(sequence, "?") {
// OSC query, we don't intend to answer those, just ignore them.
//
// Ref: https://github.com/walles/moor/issues/279
return nil
}
return fmt.Errorf("Unhandled OSC sequence")
}
// Based on https://infra.spec.whatwg.org/#surrogate
func isSurrogate(char rune) bool {
if char >= 0xD800 && char <= 0xDFFF {
return true
}
return false
}
// Non-characters end with 0xfffe or 0xffff, or are in the range 0xFDD0 to 0xFDEF.
//
// Based on https://infra.spec.whatwg.org/#noncharacter
func isNonCharacter(char rune) bool {
if char >= 0xFDD0 && char <= 0xFDEF {
return true
}
// Non-characters end with 0xfffe or 0xffff
if char&0xFFFF == 0xFFFE || char&0xFFFF == 0xFFFF {
return true
}
return false
}
// Based on https://url.spec.whatwg.org/#url-code-points
func isValidURLChar(char rune) bool {
if char == '\\' {
// Ref: https://github.com/walles/moor/issues/244#issuecomment-2350908401
return true
}
if char == '%' {
// Needed for % escapes
return true
}
if char == '#' {
// Fragment identifier
return true
}
// ASCII alphanumerics
if char >= '0' && char <= '9' {
return true
}
if char >= 'A' && char <= 'Z' {
return true
}
if char >= 'a' && char <= 'z' {
return true
}
if strings.ContainsRune("!$&'()*+,-./:;=?@_~", char) {
return true
}
if char < 0x00a0 {
return false
}
if char > 0x10FFFD {
return false
}
if isSurrogate(char) {
return false
}
if isNonCharacter(char) {
return false
}
return true
}
// We just got ESC]8; and should now read the URL. URLs end with ASCII 7 BEL or ESC \.
func (s *styledStringSplitter) handleURL() error {
// Points to right after "ESC]8;"
urlStartIndex := s.nextByteIndex
justSawEsc := false
for {
char := s.nextChar()
if char == -1 {
return fmt.Errorf("Line ended in the middle of a URL")
}
if justSawEsc {
if char != '\\' {
return fmt.Errorf("Expected ESC \\ but got ESC %q", char)
}
// End of URL
urlEndIndexExclusive := s.nextByteIndex - 2
url := s.input[urlStartIndex:urlEndIndexExclusive]
s.startNewPart(s.inProgressStyle.WithHyperlink(&url))
return nil
}
// Invariant: justSawEsc == false
if char == esc {
justSawEsc = true
continue
}
if char == '\x07' {
// End of URL
urlEndIndexExclusive := s.nextByteIndex - 1
url := s.input[urlStartIndex:urlEndIndexExclusive]
s.startNewPart(s.inProgressStyle.WithHyperlink(&url))
return nil
}
if !isValidURLChar(char) {
return fmt.Errorf("Invalid URL character: <%q>", char)
}
// It's a valid URL char, keep going
}
}
func (s *styledStringSplitter) startNewPart(style twin.Style) {
if style == twin.StyleDefault {
style = s.plainTextStyle
}
if style == s.inProgressStyle {
// No need to start a new part
return
}
s.finalizeCurrentPart()
s.inProgressString.Reset()
s.inProgressStyle = style
}
func (s *styledStringSplitter) finalizeCurrentPart() {
if s.inProgressString.Len() == 0 {
// Nothing to do
return
}
partString := s.inProgressString.String()
s.callback(partString, s.inProgressStyle)
s.reportedRunesCount += utf8.RuneCountInString(partString)
if s.maxTokensCount > 0 && s.reportedRunesCount >= s.maxTokensCount {
// We've reported enough runes, stop processing any further input
s.nextByteIndex = len(s.input)
}
}
moor-2.10.3/internal/textstyles/styledStringSplitter_test.go 0000664 0000000 0000000 00000012734 15135744745 0024453 0 ustar 00root root 0000000 0000000 package textstyles
import (
"strings"
"testing"
"github.com/walles/moor/v2/twin"
"gotest.tools/v3/assert"
)
func TestNextCharLastChar_base(t *testing.T) {
s := styledStringSplitter{
input: "a",
}
assert.Equal(t, 'a', s.nextChar())
assert.Equal(t, 'a', s.lastChar())
assert.Equal(t, rune(-1), s.nextChar())
assert.Equal(t, rune(-1), s.lastChar())
}
func TestNextCharLastChar_empty(t *testing.T) {
s := styledStringSplitter{
input: "",
}
assert.Equal(t, rune(-1), s.nextChar())
assert.Equal(t, rune(-1), s.lastChar())
}
func collectStyledStrings(s string) ([]_StyledString, twin.Style) {
styledStrings := []_StyledString{}
trailer := styledStringsFromString(twin.StyleDefault, s, nil, 0, func(str string, style twin.Style) {
styledStrings = append(styledStrings, _StyledString{str, style})
})
return styledStrings, trailer
}
// We should ignore OSC 133 sequences.
//
// Ref:
// https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md
func TestIgnorePromptHints(t *testing.T) {
// From an e-mail I got titled "moor question: "--RAW-CONTROL-CHARS" equivalent"
styledStrings, trailer := collectStyledStrings("hell\x1b]133;A\x1b\\o")
assert.Equal(t, twin.StyleDefault, trailer)
assert.Equal(t, 1, len(styledStrings))
assert.Equal(t, "hello", styledStrings[0].String)
assert.Equal(t, twin.StyleDefault, styledStrings[0].Style)
// C rather than A, different end-of-sequence, should also be ignored
styledStrings, trailer = collectStyledStrings("ass\x1b]133;C\x07ertion")
assert.Equal(t, twin.StyleDefault, trailer)
assert.Equal(t, 1, len(styledStrings))
assert.Equal(t, "assertion", styledStrings[0].String)
assert.Equal(t, twin.StyleDefault, styledStrings[0].Style)
}
// We should ignore OSC queries. They are any sequence ending with a question
// mark, and expect some response from the terminal. We are not a terminal, so we
// ignore them.
//
// Ref: https://github.com/walles/moor/issues/279
func TestIgnoreQueries(t *testing.T) {
styledStrings, trailer := collectStyledStrings("\x1b]11;?\x1b\\hello")
assert.Equal(t, twin.StyleDefault, trailer)
assert.Equal(t, 1, len(styledStrings))
assert.Equal(t, "hello", styledStrings[0].String)
assert.Equal(t, twin.StyleDefault, styledStrings[0].Style)
}
// Unsure why colon separated colors exist, but the fact is that a number of
// things emit colon separated SGR codes. And numerous terminals accept them
// (search page for "delimiter"): https://github.com/kovidgoyal/kitty/issues/7
//
// Johan got an e-mail titled "moor question: "--RAW-CONTROL-CHARS" equivalent"
// about the sequence we're testing here.
func TestColonColors(t *testing.T) {
styledStrings, trailer := collectStyledStrings("\x1b[38:5:238mhello")
assert.Equal(t, twin.StyleDefault, trailer)
assert.Equal(t, 1, len(styledStrings))
assert.Equal(t, "hello", styledStrings[0].String)
assert.Equal(t, twin.StyleDefault.WithForeground(twin.NewColor256(238)), styledStrings[0].Style)
}
// Test handling of URLs with backslashes in them. Generated by some software on
// Windows, also accepted by less.
//
// Ref: https://github.com/walles/moor/issues/244
func TestWindowsURL(t *testing.T) {
windowsPath := `src\detection\cpu\cpu_bsd.c`
windowsCwd := `C:\msys64\home\zhang\fastfetch\src\detection\cpu\cpu_bsd.c`
styledStrings, trailer := collectStyledStrings("\x1b]8;;vscode://file/" + windowsCwd + "\\" + windowsPath + "\x07" + windowsPath)
assert.Equal(t, twin.StyleDefault, trailer)
assert.Equal(t, 1, len(styledStrings))
assert.Equal(t, windowsPath, styledStrings[0].String)
}
func TestURLWithSpace(t *testing.T) {
urlPath := `hello%20there.txt`
text := `hello there.txt`
styledStrings, trailer := collectStyledStrings("\x1b]8;;file://" + urlPath + "\x1b\\" + text)
assert.Equal(t, twin.StyleDefault, trailer)
assert.Equal(t, 1, len(styledStrings))
assert.Equal(t, text, styledStrings[0].String)
assert.Assert(t, strings.Contains(styledStrings[0].Style.String(), "file://"+urlPath))
}
func TestPlainTextColor(t *testing.T) {
plainTextStyle := twin.StyleDefault.WithAttr(twin.AttrReverse)
styledStrings := []_StyledString{}
trailer := styledStringsFromString(plainTextStyle, "a\x1b[33mb\x1b[mc", nil, 0, func(str string, style twin.Style) {
styledStrings = append(styledStrings, _StyledString{str, style})
})
assert.Equal(t, 3, len(styledStrings))
assert.Equal(t, "a", styledStrings[0].String)
assert.Equal(t, plainTextStyle, styledStrings[0].Style)
assert.Equal(t, "b", styledStrings[1].String)
assert.Equal(t, plainTextStyle.WithForeground(twin.NewColor16(3)), styledStrings[1].Style)
assert.Equal(t, "c", styledStrings[2].String)
assert.Equal(t, plainTextStyle, styledStrings[2].Style)
assert.Equal(t, plainTextStyle, trailer)
}
// Ignore G0 charset resets (`ESC(B`). They are output by "tput sgr0" on at
// least TERM=xterm-256color.
//
// Ref:
// - https://github.com/walles/moor/issues/276
// - https://www.xfree86.org/4.8.0/ctlseqs.html (look for "Designate G0" and
// "USASCII")
func TestRestoreG0CharSet(t *testing.T) {
styledStrings, trailer := collectStyledStrings("\x1b(Bhello")
assert.Equal(t, twin.StyleDefault, trailer)
assert.Equal(t, 1, len(styledStrings))
assert.Equal(t, "hello", styledStrings[0].String)
assert.Equal(t, twin.StyleDefault, styledStrings[0].Style)
}
func TestUnsupportedG0CharSet(t *testing.T) {
styledStrings, trailer := collectStyledStrings("\x1b(Xhello")
assert.Equal(t, twin.StyleDefault, trailer)
assert.Equal(t, 1, len(styledStrings))
assert.Equal(t, "\x1b(Xhello", styledStrings[0].String)
}
moor-2.10.3/internal/util/ 0000775 0000000 0000000 00000000000 15135744745 0015361 5 ustar 00root root 0000000 0000000 moor-2.10.3/internal/util/format.go 0000664 0000000 0000000 00000001123 15135744745 0017175 0 ustar 00root root 0000000 0000000 package util
import "fmt"
// Formats a positive number into a string with _ between each three-group of
// digits, for numbers >= 10_000.
//
// Regarding the >= 10_000 exception:
// https://en.wikipedia.org/wiki/Decimal_separator#Exceptions_to_digit_grouping
func FormatInt(i int) string {
if i < 10_000 {
return fmt.Sprint(i)
}
result := ""
chars := []rune(fmt.Sprint(i))
addCount := 0
for i := len(chars) - 1; i >= 0; i-- {
char := chars[i]
if len(result) > 0 && addCount%3 == 0 {
result = "_" + result
}
result = string(char) + result
addCount++
}
return result
}
moor-2.10.3/internal/util/format_test.go 0000664 0000000 0000000 00000001036 15135744745 0020237 0 ustar 00root root 0000000 0000000 package util
import (
"testing"
"gotest.tools/v3/assert"
)
func TestFormatInt(t *testing.T) {
assert.Equal(t, "1", FormatInt(1))
assert.Equal(t, "10", FormatInt(10))
assert.Equal(t, "100", FormatInt(100))
// Ref: // https://en.wikipedia.org/wiki/Decimal_separator#Exceptions_to_digit_grouping
assert.Equal(t, "1000", FormatInt(1000))
assert.Equal(t, "10_000", FormatInt(10000))
assert.Equal(t, "100_000", FormatInt(100000))
assert.Equal(t, "1_000_000", FormatInt(1000000))
assert.Equal(t, "10_000_000", FormatInt(10000000))
}
moor-2.10.3/internal/util/twinlogger.go 0000664 0000000 0000000 00000000523 15135744745 0020071 0 ustar 00root root 0000000 0000000 package util
import log "github.com/sirupsen/logrus"
// TwinLogger adapts logrus to the twin.Logger interface
type TwinLogger struct{}
func (l *TwinLogger) Debug(message string) {
log.Debug(message)
}
func (l *TwinLogger) Info(message string) {
log.Info(message)
}
func (l *TwinLogger) Error(message string) {
log.Error(message)
}
moor-2.10.3/manual-test.sh 0000775 0000000 0000000 00000002045 15135744745 0015362 0 ustar 00root root 0000000 0000000 #!/bin/bash
set -e -o pipefail
# Test all permutations of the following:
# - Read from file vs from stdin
# - 'q' to quit vs 'v' to launch an editor
# - Terminal editor (nano) or GUI editor (code -w)
read -r -p "Press enter to start testing, then q to exit the pager"
clear
# With --trace we always get a non-zero exit code
./moor.sh --trace moor.sh || true
echo
read -r -p "Press enter to continue, then q to exit the pager"
clear
./moor.sh --trace