elvish-0.20.1/ 0000775 0000000 0000000 00000000000 14570151573 0013074 5 ustar 00root root 0000000 0000000 elvish-0.20.1/.cirrus.yml 0000664 0000000 0000000 00000013126 14570151573 0015207 0 ustar 00root root 0000000 0000000 test_task:
env:
ELVISH_TEST_TIME_SCALE: "20"
TEST_FLAG: -race
matrix:
# Re-enable gccgo when it has caught up.
#- name: Test on gccgo
# container:
# image: debian:unstable-slim
# setup_script:
# - apt-get -y update
# - apt-get -y install ca-certificates gccgo-12 git
# - ln -sf /usr/bin/go-12 /usr/local/bin/go
# env:
# # gccgo doesn't support race test
# TEST_FLAG: ""
- name: Test on Linux ARM64
arm_container:
# The Alpine image has segmentation faults when running test -race, so
# use Debian instead.
image: golang:1.21-bookworm
# To upgrade FreeBSD environment:
# - Bump image_family to latest: https://www.freebsd.org/releases/
# - Bump GO_VERSION to latest: https://go.dev/dl/
- name: Test on FreeBSD
freebsd_instance:
image_family: freebsd-14-0
env:
PATH: /usr/local/go/bin:$PATH
GO_VERSION: 1.21.6
go_toolchain_cache:
fingerprint_key: $GO_VERSION
folder: /usr/local/go
populate_script: |
pkg install -y curl
curl -L -o go.tar.gz https://go.dev/dl/go$GO_VERSION.freebsd-amd64.tar.gz
tar -C /usr/local -xf go.tar.gz
setup_script:
# go test -race is not compatible with ASLR, which has been enabled by
# default since FreeBSD 13
# (https://wiki.freebsd.org/AddressSpaceLayoutRandomization). LLVM
# issue: https://github.com/llvm/llvm-project/issues/53256
#
# There's also a Go bug where using go test -race with ASLR fails
# to run the tests and still reports tests as passing:
# https://github.com/golang/go/issues/65425
sysctl kern.elf64.aslr.enable=0
# To upgrade NetBSD environment:
# - Find the "VERSION:" variable for the NetBSD image:
# https://github.com/anarazel/pg-vm-images/blob/main/.cirrus.yml
# - Find the latest go1* binary package available for that version:
# http://cdn.netbsd.org/pub/pkgsrc/current/pkgsrc/index-all.html
- name: Test on NetBSD
compute_engine_instance:
image_project: pg-ci-images
image: family/pg-ci-netbsd-vanilla-9-3
platform: netbsd
env:
GO_PKG: go121
PATH: /usr/pkg/$GO_PKG/bin:$PATH
go_pkg_cache:
fingerprint_key: $GO_PKG
folder: /usr/pkg/$GO_PKG
populate_script: |
pkgin -y update
pkgin -y install $GO_PKG
# To upgrade OpenBSD environment:
# - Find the "VERSION:" variable for the FreeBSD image:
# https://github.com/anarazel/pg-vm-images/blob/main/.cirrus.yml
# - Find the go-1.* package in (edit the version in the URL):
# https://cdn.openbsd.org/pub/OpenBSD/7.3/packages/amd64/
- name: Test on OpenBSD
compute_engine_instance:
image_project: pg-ci-images
image: family/pg-ci-openbsd-vanilla-7-3
platform: openbsd
env:
PATH: /usr/local/go/bin:$PATH
go_pkg_cache:
fingerprint_key: 1.20.1
folder: /usr/local/go
populate_script: pkg_add go
go_version_script: go version
test_script: go test $TEST_FLAG ./...
build_binaries_task:
name: Build binaries
only_if: $CIRRUS_BRANCH == 'master'
alias: binaries
env:
CGO_ENABLED: "0"
container:
# Keep the Go version part in sync with
# https://github.com/elves/up/blob/master/Dockerfile
image: golang:1.21.6-alpine
go_modules_cache:
fingerprint_script: cat go.sum
folder: ~/go/pkg/mod
go_build_cache:
folder: ~/.cache/go-build
# Git is not required for building the binaries, but we need to include for Go
# to include VCS information in the binary. Also install coreutils to get a
# touch command that supports specifying the timezone.
setup_script: apk add zip git coreutils
# _bin is in .gitignore, so Git won't consider the repo dirty. This will
# impact the binary, which encodes VCS information.
build_binaries_script: |
ELVISH_BUILD_VARIANT=official ./tools/buildall.sh . _bin HEAD
binaries_artifacts:
path: _bin/**
binary_checksums_artifacts:
path: _bin/*/elvish-HEAD.sha256sum
check_binary_checksums_task:
name: Check binary checksums ($HOST)
only_if: $CIRRUS_BRANCH == 'master'
container:
image: alpine:latest
depends_on: binaries
matrix:
- env:
HOST: cdg
- env:
HOST: hkg
setup_script: apk add git curl
# Enable auto cancellation - if there is another push, only the task to
# compare the website against the newer commit should continue.
auto_cancellation: "true"
wait_website_update_script: |
ts=$(git show -s --format=%ct HEAD)
wait=10
while true; do
website_ts=$(curl -sS https://$HOST.elv.sh/commit-ts.txt)
if test -z "$website_ts"; then
echo "website has no commit-ts.txt yet"
elif test "$website_ts" -ge "$ts"; then
echo "website ($website_ts) >= CI ($ts)"
exit 0
else
echo "website ($website_ts) < CI ($ts)"
fi
sleep $wait
test $wait -lt 96 && wait=`echo "$wait * 2" | bc`
done
check_binary_checksums_script: |
curl -o checksums.zip https://api.cirrus-ci.com/v1/artifact/build/$CIRRUS_BUILD_ID/binaries/binary_checksums.zip
unzip checksums.zip
cd _bin
ret=0
for f in */elvish-HEAD.sha256sum; do
website_sum=$(curl -sS https://$HOST.dl.elv.sh/$f | awk '{print $1}')
ci_sum=$(cat $f | awk '{print $1}')
if test "$website_sum" = "$ci_sum"; then
echo "$f: website == CI ($ci_sum)"
else
echo "$f: website ($website_sum) != CI ($ci_sum)"
ret=1
fi
done
exit $ret
elvish-0.20.1/.codecov.yml 0000664 0000000 0000000 00000001307 14570151573 0015320 0 ustar 00root root 0000000 0000000 coverage:
status:
project:
default:
threshold: 0.1%
patch: off
comment: false
# The following patterns are also consumed by a hacky sed script in
# tools/prune-cover.sh, which does not support globs.
ignore:
# Exclude test helpers.
- "pkg/cli/clitest"
- "pkg/cli/histutil/test_db.go"
- "pkg/eval/evaltest"
- "pkg/eval/vals/tester.go"
- "pkg/prog/progtest"
- "pkg/store/storetest"
- "pkg/must"
# Exclude commands for manual testing.
- "pkg/cli/examples"
- "pkg/md/mdrun"
# Exclude files generated by stringer.
- "pkg/getopt/zstring.go"
- "pkg/md/zstring.go"
- "pkg/parse/zstring.go"
# Exclude the copied diff and rpc packages.
- "pkg/diff"
- "pkg/rpc"
elvish-0.20.1/.codespellrc 0000664 0000000 0000000 00000000241 14570151573 0015371 0 ustar 00root root 0000000 0000000 [codespell]
ignore-words-list = ro,upto,nd,doas,fo,shouldbe,iterm,lates,testof
skip = ./.git,./vscode/node_modules,./vscode/dist,./website/_dst,./website/*.html
elvish-0.20.1/.dockerignore 0000664 0000000 0000000 00000000004 14570151573 0015542 0 ustar 00root root 0000000 0000000 /_*
elvish-0.20.1/.gitattributes 0000664 0000000 0000000 00000000026 14570151573 0015765 0 ustar 00root root 0000000 0000000 *.go filter=goimports
elvish-0.20.1/.github/ 0000775 0000000 0000000 00000000000 14570151573 0014434 5 ustar 00root root 0000000 0000000 elvish-0.20.1/.github/workflows/ 0000775 0000000 0000000 00000000000 14570151573 0016471 5 ustar 00root root 0000000 0000000 elvish-0.20.1/.github/workflows/check_cirrus.yml 0000664 0000000 0000000 00000001764 14570151573 0021670 0 ustar 00root root 0000000 0000000 name: Check Cirrus CI
on:
check_suite:
type: ['completed']
jobs:
notify-failure:
name: Notify failure
if: github.event.check_suite.app.name == 'Cirrus CI' && github.event.check_suite.conclusion == 'failure'
runs-on: ubuntu-latest
steps:
- uses: octokit/request-action@v2.x
id: get_failed_check_run
with:
route: GET /repos/${{ github.repository }}/check-suites/${{ github.event.check_suite.id }}/check-runs?status=completed
mediaType: '{"previews": ["antiope"]}'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
echo "Cirrus CI ${{ github.event.check_suite.conclusion }} on ${{ github.event.check_suite.head_branch }} branch!"
echo "SHA ${{ github.event.check_suite.head_sha }}"
echo $MESSAGE
echo "##[error]See $CHECK_RUN_URL for details"
false
env:
CHECK_RUN_URL: ${{ fromJson(steps.get_failed_check_run.outputs.data).check_runs[0].html_url }}
elvish-0.20.1/.github/workflows/check_website.yml 0000664 0000000 0000000 00000006246 14570151573 0022023 0 ustar 00root root 0000000 0000000 name: Check website
on:
push:
branches:
- master
jobs:
check_freshness:
name: Check freshness
if: github.repository == 'elves/elvish'
runs-on: ubuntu-latest
strategy:
matrix:
host: [cdg, hkg]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Compare timestamp
timeout-minutes: 30
run: |
ts=$(git show -s --format=%ct HEAD)
wait=10
while true; do
website_ts=$(curl -sS https://${{ matrix.host }}.elv.sh/commit-ts.txt)
if test -z "$website_ts"; then
echo "website has no commit-ts.txt yet"
elif test "$website_ts" -ge "$ts"; then
echo "website ($website_ts) >= current ($ts)"
exit 0
else
echo "website ($website_ts) < current ($ts)"
fi
sleep $wait
test $wait -lt 96 && wait=`echo "$wait * 2" | bc`
done
build_binaries:
name: Build binaries
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up cache
uses: actions/cache@v3
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: buildall/${{ hashFiles('go.sum') }}/${{ github.sha }}
restore-keys: buildall/${{ hashFiles('go.sum') }}
- name: Set up Go
uses: actions/setup-go@v3
with:
# Keep this in sync with
# https://github.com/elves/up/blob/master/Dockerfile
go-version: 1.21.6
- name: Build binaries
run: ELVISH_BUILD_VARIANT=official ./tools/buildall.sh . ~/elvish-bin HEAD
- name: Upload binaries
uses: actions/upload-artifact@v3
with:
name: bin
path: ~/elvish-bin/**/*
retention-days: 7
- name: Upload binary checksums
uses: actions/upload-artifact@v3
with:
name: bin-checksums
path: ~/elvish-bin/**/elvish-HEAD.sha256sum
check_binary_checksums:
name: Check binary checksums
needs: [check_freshness, build_binaries]
strategy:
matrix:
host: [cdg, hkg]
runs-on: ubuntu-latest
steps:
- name: Download binary checksums
uses: actions/download-artifact@v3
with:
name: bin-checksums
path: elvish-bin
- name: Check binary checksums
working-directory: elvish-bin
run: |
ret=0
for f in */elvish-HEAD.sha256sum; do
website_sum=$(curl -sS https://${{ matrix.host }}.dl.elv.sh/$f | awk '{print $1}')
github_sum=$(cat $f | awk '{print $1}')
if test "$website_sum" = "$github_sum"; then
echo "$f: website == github ($github_sum)"
else
echo "$f: website ($website_sum) != github ($github_sum)"
ret=1
fi
done
if test $ret != 0; then
latest_sha=$(curl -sS -H 'Authorization: token ${{ secrets.GITHUB_TOKEN }}' -H 'Accept: application/vnd.github.VERSION.sha' https://api.github.com/repos/elves/elvish/commits/master)
if test ${{ github.sha }} != "$latest_sha"; then
echo "Ignoring the mismatch since there is a newer commit now"
ret=0
fi
fi
exit $ret
elvish-0.20.1/.github/workflows/ci.yml 0000664 0000000 0000000 00000010533 14570151573 0017611 0 ustar 00root root 0000000 0000000 name: CI
on:
push:
pull_request:
defaults:
run:
# PowerShell's behavior for -flag=value is undesirable, so run all commands with bash.
shell: bash
jobs:
test:
name: Run tests
strategy:
matrix:
os: [ubuntu, macos, windows]
old-go: [false]
go-version: [1.21.x]
include:
# Test old supported Go version
- os: ubuntu
old-go: [true]
go-version: 1.20.x
env:
ELVISH_TEST_TIME_SCALE: 20
runs-on: ${{ matrix.os }}-latest
steps:
# autocrlf is problematic for fuzz testdata.
- name: Turn off autocrlf
if: matrix.os == 'windows'
run: git config --global core.autocrlf false
- name: Checkout code
uses: actions/checkout@v3
- name: Set up cache
uses: actions/cache@v3
with:
path: |
~/go/pkg/mod
~/.cache/go-build
~/Library/Caches/go-build
~/AppData/Local/go-build
key: test/${{ matrix.os }}/${{ matrix.go-version }}/${{ hashFiles('go.sum') }}/${{ github.sha }}
restore-keys: test/${{ matrix.os }}/${{ matrix.go-version }}/${{ hashFiles('go.sum') }}/
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go-version }}
- name: Test with race detection
run: |
go test -race ./...
cd website; go test -race ./...
- name: Generate test coverage
if: ${{ !matrix.old-go }}
run: go test -coverprofile=cover -coverpkg=./pkg/... ./pkg/...
- name: Save test coverage
if: ${{ !matrix.old-go }}
uses: actions/upload-artifact@v3
with:
name: cover-${{ matrix.os == 'ubuntu' && 'linux' || matrix.os }}
path: cover
# The purpose of running benchmarks in GitHub Actions is primarily to ensure
# that the benchmark code runs and doesn't crash. GitHub Action runners don't
# have a stable enough environment to produce reliable benchmark numbers.
benchmark:
name: Run benchmarks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.21.x
- name: Run benchmarks
run: go test -bench=. -run='^$' ./...
upload-coverage:
name: Upload test coverage
strategy:
matrix:
ostype: [linux, macos, windows]
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Download test coverage
uses: actions/download-artifact@v3
with:
name: cover-${{ matrix.ostype }}
- name: Upload coverage to codecov
uses: codecov/codecov-action@v3
with:
files: ./cover
flags: ${{ matrix.ostype }}
checks:
name: Run checks
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.21.x
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install tools
run: |
go install golang.org/x/tools/cmd/stringer@latest
go install golang.org/x/tools/cmd/goimports@latest
# Keep the versions of staticcheck and codespell in sync with CONTRIBUTING.md
go install honnef.co/go/tools/cmd/staticcheck@v0.4.6
pip install --user codespell==2.2.6
- name: Run checks
run: make all-checks
check-rellinks:
name: Check relative links in website
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: 1.21.x
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install Python dependency
run: pip3 install beautifulsoup4
- name: Check relative links
run: make -C website check-rellinks
lsif:
name: Upload SourceGraph LSIF
if: github.repository == 'elves/elvish' && github.event_name == 'push'
runs-on: ubuntu-latest
container: sourcegraph/lsif-go:latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Generate LSIF data
run: lsif-go
- name: Upload LSIF data
run: src lsif upload -github-token=${{ secrets.GITHUB_TOKEN }} -ignore-upload-failure
elvish-0.20.1/.gitignore 0000664 0000000 0000000 00000000445 14570151573 0015067 0 ustar 00root root 0000000 0000000 # Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
# Project specific
cover
/_bin/
/elvish
elvish-0.20.1/.vscode/ 0000775 0000000 0000000 00000000000 14570151573 0014435 5 ustar 00root root 0000000 0000000 elvish-0.20.1/.vscode/launch.json 0000664 0000000 0000000 00000000320 14570151573 0016575 0 ustar 00root root 0000000 0000000 {
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}"
],
}
]
}
elvish-0.20.1/0.20.0-release-notes.md 0000664 0000000 0000000 00000003712 14570151573 0016702 0 ustar 00root root 0000000 0000000 Draft release notes for Elvish 0.20.0.
# Notable new features
- A new `os:` module providing access to operating system functionality.
- A new `read-bytes` command for reading a fixed number of bytes.
- New commands in the `file:` module: `file:open-output`, `file:seek` and
`file:tell`.
- Maps now have their keys sorted when printed.
- The `peach` command now has a `&num-workers` option
([#648](https://github.com/elves/elvish/issues/648)).
- The `from-json` command now supports integers of arbitrary precision, and
outputs them as exact integers rather than inexact floats.
- A new `str:fields` command ([#1689](https://b.elv.sh/1689)).
- The `order` and `compare` commands now support a `&total` option, which
allows sorting and comparing values of mixed types.
- The language server now supports showing the documentation of builtin
functions and variables on hover ([#1684](https://b.elv.sh/1684)).
- Elvish now respects the [`NO_COLOR`](https://no-color.org) environment
variable. Builtin UI elements as well as styled texts will not have colors
if it is set and non-empty.
# Notable bugfixes
- `has-value $li $v` now works correctly when `$li` is a list and `$v` is a
composite value, like a map or a list.
- A bug with how the hash code of a map was computed could lead to unexpected
results when using maps as map keys; it has now been fixed.
# Breaking changes
- The `except` keyword in the `try` command was deprecated since 0.18.0 and is
now removed. Use `catch` instead.
- The `float64` command was deprecated since 0.16.0 and emitted deprecation
warnings since 0.19.1, and is now removed. Use `num` or `inexact-num`
instead.
# Deprecated features
Deprecated features will be removed in 0.21.0.
The following deprecated features trigger a warning whenever the code is parsed
and compiled, even if it is not executed:
- The `eawk` command is now deprecated. Use `re:awk` instead.
elvish-0.20.1/CONTRIBUTING.md 0000664 0000000 0000000 00000021477 14570151573 0015340 0 ustar 00root root 0000000 0000000 # Contributor's Manual
## Human communication
The only person with direct commit access is the project's founder @xiaq. If you
intend to make user-visible changes to Elvish's behavior (as opposed to fixing
typos and obvious bugs), it is good idea to talk to him first; this will make it
easier to review your changes. He should be reachable in the user group most of
the time.
On the other hand, if you find it easier to express your thoughts directly in
code, it is also completely fine to directly send a pull request, as long as you
don't mind the risk of the PR being rejected due to lack of prior discussion.
## Development workflows
The [`Makefile`](Makefile) encapsulates common development workflows:
- Use `make fmt` to [format files](#formatting-files).
- Use `make test` to [run tests](#testing-changes).
- Use `make all-checks` or `make most-checks` to
[run checks](#running-checks).
You can use the [`tools/pre-push`](tools/pre-push) script as a Git hook, which
runs all the tests and checks (`make test all-checks`), among other things.
The same tests and checks are also run by Elvish's CI environments, so running
them locally before pushing minimizes the chance of CI errors. (The CI
environments run the tests on multiple platforms, so CI errors can still happen
if you break some tests for a different platform.)
## Formatting files
Use `make fmt` to format Go and Markdown files in the repo.
### Formatting Go files on save
The Go plugins of most popular editors already support formatting Go files
automatically on save; consult the documentation of the plugin you use.
### Formatting Markdown files on save
The Markdown formatter is [`cmd/elvmdfmt`](cmd/elvmdfmt), which lives inside
this repo. Run it like this:
```sh
go run src.elv.sh/cmd/elvmdfmt -width 80 -w $filename
```
To format Markdown files automatically on save, configure your editor to run the
command above when saving Markdown files. You'll also want to configure this
command to only run inside the Elvish repo, since `elvmdfmt` is tailored to
Markdown files in this repo and may not work well for other Markdown files.
If you use VS Code, install the
[Run on Save](https://marketplace.visualstudio.com/items?itemName=emeraldwalk.RunOnSave)
extension and add the following to the workspace (**not** user) `settings.json`
file:
```json
"emeraldwalk.runonsave": {
"commands": [
{
"match": "\\.md$",
"cmd": "go run src.elv.sh/cmd/elvmdfmt -width 80 -w ${file}"
}
]
}
```
**Note**: Using `go run` ensures that you are always using the `elvmdfmt`
implementation in the repo, but it incurs a small performance penalty since the
Go toolchain does not cache binary files and has to rebuild it every time. If
this is a problem (for example, if your editor runs the command synchronously),
you can speed up the command by installing `src.elv.sh/cmd/elvmdfmt` and using
the installed `elvmdfmt`. However, if you do this, you must re-install
`elvmdfmt` whenever there is a change in its implementation that impacts the
output.
## Testing changes
Write comprehensive unit tests for your code, and make sure that existing tests
are passing. Run tests with `make test`.
Respect established patterns of how unit tests are written. Some packages
unfortunately have competing patterns, which usually reflects a still-evolving
idea of how to best test the code. Worse, parts of the codebase are poorly
tested, or even untestable. In either case, discuss with the project lead on the
best way forward.
### ELVISH_TEST_TIME_SCALE
Some unit tests depend on time thresholds. The default values of these time
thresholds are suitable for a reasonably powerful laptop, but on
resource-constraint environments (virtual machines, embedded systems) they might
not be enough.
Set the `ELVISH_TEST_TIME_SCALE` environment variable to a number greater than 1
to scale up the time thresholds used in tests. The CI environments use
`ELVISH_TEST_TIME_SCALE = 10`.
### Mocking dependencies
Whenever possible, test the real thing.
However, there are situations where it's infeasible to test the real thing, like
syscall errors that can't be reliably triggered, or tests that rely on exact
timing. In those cases, introduce a variable that stores the actual dependency
(manual dependency injection):
```go
// f.go
package pkg
import "os"
var osSleep = os.Sleep
func F() {
// Use osSleep instead of os.Sleep
}
```
And then use `testutil.Set` to override it for the duration of a test:
```go
// f_test.go
package pkg
import "testing"
func TestF(t *testing.T) {
testutil.Set(&osSleep, func(d Duration) {
// Fake implementation
})
// Now test F
}
```
If the test is in an external test package, the dependency variable will have to
be exported. Instead of exporting it directly in the implementation file, export
a pointer to it in a internal test file:
```go
// testexport_test.go
package pkg // Note: internal
var OSSleep = &os.Sleep
// f_test.go
package pkg_test // Note: external
import (
"pkg"
"testing"
)
func TestF(t *testing.T) {
// Note: No more & since pkg.OSSleep is already a pointer
testutil.Set(pkg.OSSleep, func(d Duration) {
// Fake implementation
})
// Now test F
}
```
## Documenting changes
Always document user-visible changes.
### Release notes
Add a brief list item to the release note of the next release, in the
appropriate section. You can find the document at the root of the repo (called
`$version-release-notes.md`).
### Reference docs
Reference docs are written as "elvdocs", comment blocks before unindented `fn`
or `var` declarations in Elvish files. A
[large subset](https://pkg.go.dev/src.elv.sh/pkg/md@master) of
[CommonMark](https://commonmark.org) is supported. Examples:
````elvish
# Does something.
#
# Examples:
#
# ```elvish-transcript
# ~> foo
# some output
# ```
fn foo {|a b c| }
# Some variable.
var bar
````
Most of Elvish's builtin modules are implemented in Go, not Elvish. For those
modules, put dummy declarations in `.d.elv` files (`d` for "declaration"). For
example, elvdocs for functions implemented in `builtin_fn_num.go` go in
`builtin_fn_num.d.elv`.
For a comment block to be considered an elvdoc, it has to be continuous, and
each line should either be just `#` or start with `#` and a space.
Style guides for elvdocs for functions:
- The first sentence should start with a verb in 3rd person singular (i.e.
ending with a "s"), as if there is an implicit subject "this function".
- The end of the elvdoc should show or more `elvish-transcript` code blocks
showing example usages, which are transcripts of actual REPL input and
output. Transcripts must use the default prompt `~>` and default value
output indicator `▶`. You can use `elvish -norc` if you have customized
either in your [`rc.elv`](https://elv.sh/ref/command.html#rc-file).
It is quite common for elvdocs to link to other elvdocs, and Elvish's website
toolchain provides special support for that. If a link has a single code span
and an empty target, it gets rewritten to a link to an elvdoc section. For
example, ``[`put`]()`` will get rewritten to ``[`put`](builtin.html#put)``, or
just ``[`put`](#put)`` within the documentation for the builtin module.
### Comment for unexported Go types and functions
In the doc comment for exported types and functions, it's customary to use the
symbol itself as the first word of the comment. For unexported types and
functions, this becomes a bit awkward as their names don't start with a capital
letter, so don't repeat the symbol. Examples:
```go
// Foo does foo.
func Foo() { }
// Does foo.
func foo() { }
```
## Generating code
Elvish uses generated code in a few places. As is the usual case with Go
projects, they are committed into the repo, and if you change the input of a
generated file you should re-generate it.
Use the standard command, `go generate ./...` to regenerate all files.
Some of the generation rules depend on the `stringer` tool. Install with
`go install golang.org/x/tools/cmd/stringer@latest`.
## Running checks
There are some checks on the source code that can be run with `make all-checks`
or `make most-checks`. The difference is that `all-checks` includes a check
([`tools/check-gen.sh`](tools/check-gen.sh)) that requires the Git repo to have
a clean working tree, so may not be convenient to use when you are working on
the source code. The `most-checks` target excludes that, so can be always be
used.
The checks depend on some external programs, which can be installed as follows:
```sh
go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@v0.4.6
pip install --user codespell==2.2.6
```
## Licensing
By contributing, you agree to license your code under the same license as
existing source code of elvish. See the LICENSE file.
elvish-0.20.1/Dockerfile 0000664 0000000 0000000 00000000553 14570151573 0015071 0 ustar 00root root 0000000 0000000 FROM golang:1.20-alpine as builder
RUN apk add --no-cache --virtual build-deps make git
# Build Elvish
COPY . /go/src/src.elv.sh
RUN make -C /go/src/src.elv.sh get
FROM alpine:3.18
RUN adduser -D elf
RUN apk update && apk add tmux mandoc man-pages vim curl sqlite git
COPY --from=builder /go/bin/elvish /bin/elvish
USER elf
WORKDIR /home/elf
CMD ["/bin/elvish"]
elvish-0.20.1/LICENSE 0000664 0000000 0000000 00000002424 14570151573 0014103 0 ustar 00root root 0000000 0000000 Copyright (c) Elvish developers and contributors. All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 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 HOLDER 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.
elvish-0.20.1/Makefile 0000664 0000000 0000000 00000003056 14570151573 0014540 0 ustar 00root root 0000000 0000000 ELVISH_MAKE_BIN ?= $(or $(GOBIN),$(shell go env GOPATH)/bin)/elvish$(shell go env GOEXE)
ELVISH_MAKE_BIN := $(subst \,/,$(ELVISH_MAKE_BIN))
ELVISH_MAKE_PKG ?= ./cmd/elvish
default: test most-checks get
# This target emulates the behavior of "go install ./cmd/elvish", except that
# the build output and the main package to build can be overridden with
# environment variables.
get:
mkdir -p $(shell dirname $(ELVISH_MAKE_BIN))
go build -o $(ELVISH_MAKE_BIN) $(ELVISH_MAKE_PKG)
# Run formatters on Go and Markdown files.
fmt:
find . -name '*.go' | xargs goimports -w
find . -name '*.go' | xargs gofmt -s -w
find . -name '*.md' | xargs go run src.elv.sh/cmd/elvmdfmt -w -width 80
# Run unit tests, with race detection if the platform supports it.
test:
go test $(shell ./tools/run-race.sh) ./...
cd website; go test $(shell ./tools/run-race.sh) ./...
# Generate a basic test coverage report, and open it in the browser. The report
# is an approximation of https://app.codecov.io/gh/elves/elvish/.
cover:
go test -coverprofile=cover -coverpkg=./pkg/... ./pkg/...
./tools/prune-cover.sh .codecov.yml cover
go tool cover -html=cover
go tool cover -func=cover | tail -1 | awk '{ print "Overall coverage:", $$NF }'
# All the checks except check-gen.sh, which is not always convenient to run as
# it requires a clean working tree.
most-checks:
./tools/check-fmt-go.sh
./tools/check-fmt-md.sh
./tools/check-disallowed.sh
codespell
go vet ./...
staticcheck ./...
all-checks: most-checks
./tools/check-gen.sh
.PHONY: default get fmt test cover most-checks all-checks
elvish-0.20.1/PACKAGING.md 0000664 0000000 0000000 00000006613 14570151573 0014710 0 ustar 00root root 0000000 0000000 # Packager's Manual
The main package of Elvish is `cmd/elvish`, and you can build it like any other
Go application. None of the instructions below are strictly required.
**Note**: The guidance here applies to the current development version and
release versions starting from 0.19.0. The details for earlier versions are
different.
## Identifying the build variant
You are encouraged to identify your build by overriding
`src.elv.sh/pkg/buildinfo.BuildVariant` with something that identifies the
distribution you are building for, and any patch level you have applied for
Elvish. This will allow Elvish developers to easily identify any
distribution-specific issue:
```sh
go build -ldflags '-X src.elv.sh/pkg/buildinfo.BuildVariant=deb1' ./cmd/elvish
```
### Official builds
A special build variant is `official`. This variant has a special meaning: the
binary must be bit-by-bit identical to the official binaries, linked from
https://elv.sh/get.
The official binaries are built using the `tools/buildall.sh` script in the Git
repo, using the docker image defined in https://github.com/elves/up. If you can
fully mirror the environment **and** verify that the resulting binary is
bit-by-bit identical to the official one, you can identify your build as
`official`.
**Important**: Reproducing the official binaries is not a one-off investment,
but an ongoing commitment: whenever the configuration for the official builds
changes, you must update your build setup accordingly. You must always verify
that your binaries are identical to official ones. As an example of how this can
be done, the
[check website](https://github.com/elves/elvish/blob/master/.github/workflows/check_website.yml)
workflow builds binaries for commits on the `master` branch and compare them
with official ones. If your build setup is technically reproducible, but you are
ready to ensure it's always identical to official binaries, you can always use a
distribution-specific variant, such as `deb1-reproducible`.
## Supplying VCS information for development builds
**Note**: This section is only relevant when building **development commits**,
which most distributions do not provide. Release commits always have their
version hardcoded in the code.
When Elvish is built from a development branch, it will try to figure out its
version from the VCS information Go compiler encoded. When that works,
`elvish -version` will output something like this:
```
0.19.0-dev.0.20220320172241-5dc8c02a32cf
```
The version string follows the syntax of
[Go module pseudo-version](https://go.dev/ref/mod#pseudo-versions), and consists
of the following parts:
- `0.19.0-dev` identifies that this is a development build **before** the
0.19.0 release.
- `.0` indicates that this is a pseudo-version, instead of a real version.
- `20220320172241` identifies the commit's creation time, in UTC.
- `5dc8c02a32cf` is the 12-character prefix of the commit hash.
If that doesn't work for your build environment, the output of `elvish -version`
will instead be:
```
0.19.0-dev.unknown
```
If your build environment has the required information to build the
pseudo-version string, you can supply it by overriding
`src.elv.sh/pkg/buildinfo.VCSOverride` with the last two parts of the version
string, commit's creation time and the 12-character prefix of the commit hash:
```sh
go build -ldflags '-X src.elv.sh/pkg/buildinfo.VCSOverride=20220320172241-5dc8c02a32cf' ./cmd/elvish
```
elvish-0.20.1/README.md 0000664 0000000 0000000 00000015106 14570151573 0014356 0 ustar 00root root 0000000 0000000 # Elvish: Expressive Programming Language + Versatile Interactive Shell
[](https://github.com/elves/elvish/actions?query=workflow%3ACI)
[](https://cirrus-ci.com/github/elves/elvish/master)
[](https://app.codecov.io/gh/elves/elvish/tree/master)
[](https://pkg.go.dev/src.elv.sh@master)
[](https://repology.org/project/elvish/versions)
[](https://twitter.com/ElvishShell)
Elvish is an expressive programming language and a versatile interactive shell,
combined into one seamless package. It runs on Linux, BSDs, macOS and Windows.
Despite its pre-1.0 status, it is already suitable for most daily interactive
use.
User groups (all connected thanks to [Matrix](https://matrix.org)):
[](https://gitter.im/elves/elvish)
[](https://telegram.me/elvish)
[](https://web.libera.chat/#elvish)
[](https://matrix.to/#/#users:elv.sh)
## Documentation
Documentation for Elvish lives on the official website https://elv.sh,
including:
- [Learning material](https://elv.sh/learn)
- [Reference docs](https://elv.sh/ref), including the
[language reference](https://elv.sh/ref/language.html),
[the `elvish` command](https://elv.sh/ref/command.html), and all the modules
in the standard library
- [Blog posts](https://elv.sh/blog), including release notes
The source for the documentation is in the
[website](https://github.com/elves/elvish/tree/master/website) directory.
## License
All source files use the BSD 2-clause license (see [LICENSE](LICENSE)), except
for the following:
- Files in [pkg/diff](pkg/diff) and [pkg/rpc](pkg/rpc) are released under the
BSD 3-clause license, since they are derived from
[Go's source code](https://github.com/golang/go). See
[pkg/diff/LICENSE](pkg/diff/LICENSE) and [pkg/rpc/LICENSE](pkg/rpc/LICENSE).
- Files in [pkg/persistent](pkg/persistent) and its subdirectories are
released under EPL 1.0, since they are partially derived from
[Clojure's source code](https://github.com/clojure/clojure). See
[pkg/persistent/LICENSE](pkg/persistent/LICENSE).
- Files in [pkg/md/spec](pkg/md/spec) are released under the Creative Commons
CC-BY-SA 4.0 license, since they are derived from
[the CommonMark spec](https://github.com/commonmark/commonmark-spec). See
[pkg/md/spec/LICENSE](pkg/md/spec/LICENSE).
## Building Elvish
Most users do not need to build Elvish from source. Prebuilt binaries for the
latest commit are provided for
[Linux amd64](https://dl.elv.sh/linux-amd64/elvish-HEAD.tar.gz),
[macOS amd64](https://dl.elv.sh/darwin-amd64/elvish-HEAD.tar.gz),
[macOS arm64](https://dl.elv.sh/darwin-arm64/elvish-HEAD.tar.gz),
[Windows amd64](https://dl.elv.sh/windows-amd64/elvish-HEAD.zip) and
[many other platforms](https://elv.sh/get).
To build Elvish from source, you need
- A supported OS: Linux, {Free,Net,Open}BSD, macOS, or Windows 10. Windows 10
support is experimental.
- Go >= 1.20.
To build Elvish from source, run one of the following commands:
```sh
go install src.elv.sh/cmd/elvish@master # Install latest commit
go install src.elv.sh/cmd/elvish@latest # Install latest released version
go install src.elv.sh/cmd/elvish@v0.18.0 # Install a specific version
```
### Controlling the installation location
The
[`go install`](https://pkg.go.dev/cmd/go#hdr-Compile_and_install_packages_and_dependencies)
command installs Elvish to `$GOBIN`; the binary name is `elvish`. You can
control the installation location by overriding `$GOBIN`, for example by
prepending `env GOBIN=...` to the `go install` command.
If `$GOBIN` is not set, the installation location defaults to `$GOPATH/bin`,
which in turn defaults to `~/go/bin` if `$GOPATH` is also not set.
The installation directory is probably not in your OS's default `$PATH`. You
should either either add it to `$PATH`, or manually copy the Elvish binary to a
directory already in `$PATH`.
### Building a variant
Elvish has several *build variants* with slightly different feature sets. For
example, the `withpprof` build variant has
[profiling support](https://pkg.go.dev/runtime/pprof).
These build variants are just alternative main packages. For example, to build
the `withpprof` variant, run the following command (change the part after `@` to
get different versions):
```sh
go install src.elv.sh/cmd/withpprof/elvish@master
```
### Building from a local source tree
If you are modifying Elvish's source code, you will want to clone Elvish's Git
repository and build Elvish from the local source tree instead. To do this, run
the following from the root of the source tree:
```sh
go install ./cmd/elvish
```
There is no need to specify a version like `@master`; when inside a source tree,
`go install` will always use the whatever source code is present.
See [CONTRIBUTING.md](CONTRIBUTING.md) for more notes for contributors.
### Building with experimental plugin support
Elvish has experimental support for building and importing plugins, modules
written in Go. It relies on Go's [plugin support](https://pkg.go.dev/plugin),
which is only available on a few platforms.
Plugin support requires building Elvish with [cgo](https://pkg.go.dev/cmd/cgo).
The official [prebuilt binaries](https://elv.sh/get) are built without cgo for
compatibility and reproducibility, but by default the Go toolchain builds with
cgo enabled.
If you have built Elvish from source on a platform with plugin support, your
Elvish build probably already supports plugins. To force cgo to be used when
building Elvish, you can do the following:
```sh
env CGO_ENABLED=1 go install ./cmd/elvish
```
To build a plugin, see this [example](https://github.com/elves/sample-plugin).
## Packaging Elvish
See [PACKAGING.md](PACKAGING.md) for notes for packagers.
## Contributing to Elvish
See [CONTRIBUTING.md](CONTRIBUTING.md) for notes for contributors.
## Reporting security issues
See [SECURITY.md](SECURITY.md) for how to report security issues.
elvish-0.20.1/SECURITY.md 0000664 0000000 0000000 00000000713 14570151573 0014666 0 ustar 00root root 0000000 0000000 # Security Policy
## Supported Versions
Only the HEAD and the last release is supported by the developers of Elvish.
However, since some operating systems contain outdated Elvish packages, please
also feel free to get in touch for security issues in unsupported versions. You
can check the versions of Elvish packages on
[Repology](https://repology.org/project/elvish/versions).
## Reporting a Vulnerability
Please contact Qi Xiao at xiaqqaix@gmail.com.
elvish-0.20.1/cmd/ 0000775 0000000 0000000 00000000000 14570151573 0013637 5 ustar 00root root 0000000 0000000 elvish-0.20.1/cmd/elvish/ 0000775 0000000 0000000 00000000000 14570151573 0015131 5 ustar 00root root 0000000 0000000 elvish-0.20.1/cmd/elvish/main.go 0000664 0000000 0000000 00000001253 14570151573 0016405 0 ustar 00root root 0000000 0000000 // Elvish is a cross-platform shell, supporting Linux, BSDs and Windows. It
// features an expressive programming language, with features like namespacing
// and anonymous functions, and a fully programmable user interface with
// friendly defaults. It is suitable for both interactive use and scripting.
package main
import (
"os"
"src.elv.sh/pkg/buildinfo"
"src.elv.sh/pkg/daemon"
"src.elv.sh/pkg/lsp"
"src.elv.sh/pkg/prog"
"src.elv.sh/pkg/shell"
)
func main() {
os.Exit(prog.Run(
[3]*os.File{os.Stdin, os.Stdout, os.Stderr}, os.Args,
prog.Composite(
&buildinfo.Program{}, &daemon.Program{}, &lsp.Program{},
&shell.Program{ActivateDaemon: daemon.Activate})))
}
elvish-0.20.1/cmd/elvmdfmt/ 0000775 0000000 0000000 00000000000 14570151573 0015455 5 ustar 00root root 0000000 0000000 elvish-0.20.1/cmd/elvmdfmt/main.go 0000664 0000000 0000000 00000004210 14570151573 0016725 0 ustar 00root root 0000000 0000000 // Command elvmdfmt reformats Markdown sources.
//
// This command is used to reformat all Markdown files in this repo; see the
// [contributor's manual] on how to use it.
//
// For general information about the Markdown implementation used by this
// command, see [src.elv.sh/pkg/md].
//
// [contributor's manual]: https://github.com/elves/elvish/blob/master/CONTRIBUTING.md#formatting
package main
import (
"flag"
"fmt"
"html"
"io"
"os"
"src.elv.sh/pkg/diff"
"src.elv.sh/pkg/md"
)
var (
overwrite = flag.Bool("w", false, "write result to source file (requires -fmt)")
showDiff = flag.Bool("d", false, "show diff")
width = flag.Int("width", 0, "if > 0, reflow content to width")
)
func main() {
md.UnescapeHTML = html.UnescapeString
flag.Parse()
files := flag.Args()
if len(files) == 0 {
text, err := io.ReadAll(os.Stdin)
handleReadError("stdin", err)
result, unsupported := format(string(text))
fmt.Print(result)
handleUnsupported("stdin", unsupported)
return
}
for _, file := range files {
textBytes, err := os.ReadFile(file)
handleReadError(file, err)
text := string(textBytes)
result, unsupported := format(text)
handleUnsupported(file, unsupported)
if *overwrite {
err := os.WriteFile(file, []byte(result), 0644)
if err != nil {
fmt.Fprintf(os.Stderr, "write %s: %v\n", file, err)
os.Exit(2)
}
} else if !*showDiff {
fmt.Print(result)
}
if *showDiff {
os.Stdout.Write(diff.Diff(file+".orig", text, file, result))
}
}
}
func format(original string) (string, *md.FmtUnsupported) {
codec := &md.FmtCodec{Width: *width}
formatted := md.RenderString(original, codec)
return formatted, codec.Unsupported()
}
func handleReadError(name string, err error) {
if err != nil {
fmt.Fprintf(os.Stderr, "read %s: %v\n", name, err)
os.Exit(2)
}
}
func handleUnsupported(name string, u *md.FmtUnsupported) {
if u == nil {
return
}
if u.NestedEmphasisOrStrongEmphasis {
fmt.Fprintln(os.Stderr, name, "contains nested emphasis or strong emphasis")
}
if u.ConsecutiveEmphasisOrStrongEmphasis {
fmt.Fprintln(os.Stderr, name, "contains consecutive emphasis or strong emphasis")
}
os.Exit(2)
}
elvish-0.20.1/cmd/nodaemon/ 0000775 0000000 0000000 00000000000 14570151573 0015437 5 ustar 00root root 0000000 0000000 elvish-0.20.1/cmd/nodaemon/elvish/ 0000775 0000000 0000000 00000000000 14570151573 0016731 5 ustar 00root root 0000000 0000000 elvish-0.20.1/cmd/nodaemon/elvish/main.go 0000664 0000000 0000000 00000000623 14570151573 0020205 0 ustar 00root root 0000000 0000000 // Command elvish is an alternative main program of Elvish that does not include
// the daemon subprogram.
package main
import (
"os"
"src.elv.sh/pkg/buildinfo"
"src.elv.sh/pkg/lsp"
"src.elv.sh/pkg/prog"
"src.elv.sh/pkg/shell"
)
func main() {
os.Exit(prog.Run(
[3]*os.File{os.Stdin, os.Stdout, os.Stderr}, os.Args,
prog.Composite(&buildinfo.Program{}, &lsp.Program{}, &shell.Program{})))
}
elvish-0.20.1/cmd/withpprof/ 0000775 0000000 0000000 00000000000 14570151573 0015661 5 ustar 00root root 0000000 0000000 elvish-0.20.1/cmd/withpprof/elvish/ 0000775 0000000 0000000 00000000000 14570151573 0017153 5 ustar 00root root 0000000 0000000 elvish-0.20.1/cmd/withpprof/elvish/main.go 0000664 0000000 0000000 00000001010 14570151573 0020416 0 ustar 00root root 0000000 0000000 // Command elvish is an alternative main program of Elvish that supports writing
// pprof profiles.
package main
import (
"os"
"src.elv.sh/pkg/buildinfo"
"src.elv.sh/pkg/daemon"
"src.elv.sh/pkg/lsp"
"src.elv.sh/pkg/pprof"
"src.elv.sh/pkg/prog"
"src.elv.sh/pkg/shell"
)
func main() {
os.Exit(prog.Run(
[3]*os.File{os.Stdin, os.Stdout, os.Stderr}, os.Args,
prog.Composite(
&pprof.Program{}, &buildinfo.Program{}, &daemon.Program{}, &lsp.Program{},
&shell.Program{ActivateDaemon: daemon.Activate})))
}
elvish-0.20.1/go.mod 0000664 0000000 0000000 00000000444 14570151573 0014204 0 ustar 00root root 0000000 0000000 module src.elv.sh
require (
github.com/creack/pty v1.1.21
github.com/google/go-cmp v0.6.0
github.com/mattn/go-isatty v0.0.20
github.com/sourcegraph/jsonrpc2 v0.2.0
go.etcd.io/bbolt v1.3.8
golang.org/x/sync v0.6.0
golang.org/x/sys v0.16.0
pkg.nimblebun.works/go-lsp v1.1.0
)
go 1.20
elvish-0.20.1/go.sum 0000664 0000000 0000000 00000003540 14570151573 0014231 0 ustar 00root root 0000000 0000000 github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/sourcegraph/jsonrpc2 v0.2.0 h1:KjN/dC4fP6aN9030MZCJs9WQbTOjWHhrtKVpzzSrr/U=
github.com/sourcegraph/jsonrpc2 v0.2.0/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA=
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
pkg.nimblebun.works/go-lsp v1.1.0 h1:TH5ro4p2vlDtELK4LoVeKs4TsKm6aW1f5WP8jHm/9m4=
pkg.nimblebun.works/go-lsp v1.1.0/go.mod h1:Suh759Ki+DjU0zwf0xkl1H6Ln1C6/+GtYyNofbtfcug=
elvish-0.20.1/pkg/ 0000775 0000000 0000000 00000000000 14570151573 0013655 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/buildinfo/ 0000775 0000000 0000000 00000000000 14570151573 0015630 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/buildinfo/buildinfo.go 0000664 0000000 0000000 00000007433 14570151573 0020141 0 ustar 00root root 0000000 0000000 // Package buildinfo contains build information.
//
// Some of the exported fields may be set during compilation by passing -ldflags
// "-X src.elv.sh/pkg/buildinfo.Var=value" to "go build".
package buildinfo
import (
"encoding/json"
"fmt"
"os"
"runtime"
"runtime/debug"
"strings"
"time"
"src.elv.sh/pkg/must"
"src.elv.sh/pkg/prog"
)
// VersionBase identifies the version of Elvish. On the development branches, it
// identifies the next release.
const VersionBase = "0.20.1"
// VCSOverride may be set during compilation to "time-commit" (e.g.
// "20220320172241-5dc8c02a32cf") for identifying the version of development
// builds.
//
// It is only needed if the automatic population of version information
// implemented in devVersion fails.
//
// This variable is ignored on release branches.
var VCSOverride string
// BuildVariant may be set during compilation to identify a particular
// build variant, such as a build by a specific distribution, with modified
// dependencies, or with a non-standard toolchain.
//
// If non-empty, it is appended to the version string, along with a "+" prefix.
var BuildVariant string
// Type contains all the build information fields.
type Type struct {
Version string `json:"version"`
GoVersion string `json:"goversion"`
}
func (Type) IsStructMap() {}
// Value contains all the build information.
var Value = Type{
// On a release branch, change to addVariant(VersionBase, BuildVariant).
Version: addVariant(VersionBase, BuildVariant),
GoVersion: runtime.Version(),
}
func addVariant(version, variant string) string {
if variant != "" {
version += "+" + variant
}
return version
}
var readBuildInfo = debug.ReadBuildInfo
func devVersion(next, vcsOverride string) string {
if vcsOverride != "" {
return next + "-dev.0." + vcsOverride
}
fallback := next + "-dev.unknown"
bi, ok := readBuildInfo()
if !ok {
return fallback
}
// If the main module's version is known, use it, but without the "v"
// prefix. This is the case when Elvish is built with "go install
// src.elv.sh/cmd/elvish@version".
if v := bi.Main.Version; v != "" && v != "(devel)" {
return strings.TrimPrefix(v, "v")
}
// If VCS information is available (i.e. when Elvish is built from a checked
// out repo), build the version string with it. Emulate the format of pseudo
// version (https://go.dev/ref/mod#pseudo-versions).
m := make(map[string]string)
for _, s := range bi.Settings {
if k := strings.TrimPrefix(s.Key, "vcs."); k != s.Key {
m[k] = s.Value
}
}
if m["revision"] == "" || m["time"] == "" || m["modified"] == "" {
return fallback
}
t, err := time.Parse(time.RFC3339Nano, m["time"])
if err != nil {
return fallback
}
revision := m["revision"]
if len(revision) > 12 {
revision = revision[:12]
}
version := fmt.Sprintf("%s-dev.0.%s-%s", next, t.Format("20060102150405"), revision)
if m["modified"] == "true" {
return version + "-dirty"
}
return version
}
// Program is the buildinfo subprogram.
type Program struct {
version, buildinfo bool
json *bool
}
func (p *Program) RegisterFlags(fs *prog.FlagSet) {
fs.BoolVar(&p.version, "version", false,
"Output the Elvish version and quit")
fs.BoolVar(&p.buildinfo, "buildinfo", false,
"Output information about the Elvish build and quit")
p.json = fs.JSON()
}
func (p *Program) Run(fds [3]*os.File, _ []string) error {
switch {
case p.buildinfo:
if *p.json {
fmt.Fprintln(fds[1], mustToJSON(Value))
} else {
fmt.Fprintln(fds[1], "Version:", Value.Version)
fmt.Fprintln(fds[1], "Go version:", Value.GoVersion)
}
case p.version:
if *p.json {
fmt.Fprintln(fds[1], mustToJSON(Value.Version))
} else {
fmt.Fprintln(fds[1], Value.Version)
}
default:
return prog.NextProgram()
}
return nil
}
func mustToJSON(v any) string {
return string(must.OK1(json.Marshal(v)))
}
elvish-0.20.1/pkg/buildinfo/buildinfo_test.go 0000664 0000000 0000000 00000006361 14570151573 0021177 0 ustar 00root root 0000000 0000000 package buildinfo
import (
"fmt"
"runtime"
"runtime/debug"
"testing"
"src.elv.sh/pkg/eval/vals"
"src.elv.sh/pkg/prog/progtest"
"src.elv.sh/pkg/testutil"
)
var ThatElvish = progtest.ThatElvish
func TestProgram(t *testing.T) {
progtest.Test(t, &Program{},
ThatElvish("-version").WritesStdout(Value.Version+"\n"),
ThatElvish("-version", "-json").WritesStdout(mustToJSON(Value.Version)+"\n"),
ThatElvish("-buildinfo").WritesStdout(
fmt.Sprintf(
"Version: %v\nGo version: %v\n", Value.Version, Value.GoVersion)),
ThatElvish("-buildinfo", "-json").WritesStdout(mustToJSON(Value)+"\n"),
ThatElvish().ExitsWith(2).WritesStderr("internal error: no suitable subprogram\n"),
)
}
var devVersionTests = []struct {
name string
next string
vcsOverride string
buildInfo *debug.BuildInfo
want string
}{
{
name: "no BuildInfo",
next: "0.42.0",
want: "0.42.0-dev.unknown",
},
{
name: "BuildInfo with Main.Version = (devel)",
next: "0.42.0",
buildInfo: &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}},
want: "0.42.0-dev.unknown",
},
{
name: "BuildInfo with non-empty Main.Version != (devel)",
next: "0.42.0",
buildInfo: &debug.BuildInfo{Main: debug.Module{Version: "v0.42.0-dev.foobar"}},
want: "0.42.0-dev.foobar",
},
{
name: "BuildInfo with VCS data from clean checkout",
next: "0.42.0",
buildInfo: &debug.BuildInfo{Settings: []debug.BuildSetting{
{Key: "vcs.revision", Value: "1234567890123456"},
{Key: "vcs.time", Value: "2022-04-01T23:59:58Z"},
{Key: "vcs.modified", Value: "false"},
}},
want: "0.42.0-dev.0.20220401235958-123456789012",
},
{
name: "BuildInfo with VCS data from dirty checkout",
next: "0.42.0",
buildInfo: &debug.BuildInfo{Settings: []debug.BuildSetting{
{Key: "vcs.revision", Value: "1234567890123456"},
{Key: "vcs.time", Value: "2022-04-01T23:59:58Z"},
{Key: "vcs.modified", Value: "true"},
}},
want: "0.42.0-dev.0.20220401235958-123456789012-dirty",
},
{
name: "BuildInfo with unknown VCS timestamp format",
next: "0.42.0",
buildInfo: &debug.BuildInfo{Settings: []debug.BuildSetting{
{Key: "vcs.revision", Value: "1234567890123456"},
{Key: "vcs.time", Value: "April First"},
{Key: "vcs.modified", Value: "false"},
}},
want: "0.42.0-dev.unknown",
},
{
name: "vcsOverride",
next: "0.42.0",
vcsOverride: "20220401235958-123456789012",
want: "0.42.0-dev.0.20220401235958-123456789012",
},
}
func TestDevVersion(t *testing.T) {
for _, test := range devVersionTests {
t.Run(test.name, func(t *testing.T) {
testutil.Set(t, &readBuildInfo,
func() (*debug.BuildInfo, bool) {
return test.buildInfo, test.buildInfo != nil
})
got := devVersion(test.next, test.vcsOverride)
if got != test.want {
t.Errorf("got %q, want %q", got, test.want)
}
})
}
}
func TestAddVariant(t *testing.T) {
got := addVariant("0.42.0", "")
want := "0.42.0"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
got = addVariant("0.42.0", "distro")
want = "0.42.0+distro"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestValue(t *testing.T) {
vals.TestValue(t, Value).
Index("version", Value.Version).
Index("go-version", runtime.Version())
}
elvish-0.20.1/pkg/cli/ 0000775 0000000 0000000 00000000000 14570151573 0014424 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/cli/app.go 0000664 0000000 0000000 00000030111 14570151573 0015527 0 ustar 00root root 0000000 0000000 // Package cli implements a generic interactive line editor.
package cli
import (
"io"
"os"
"sort"
"sync"
"syscall"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/sys"
"src.elv.sh/pkg/ui"
)
// App represents a CLI app.
type App interface {
// ReadCode requests the App to read code from the terminal by running an
// event loop. This function is not re-entrant.
ReadCode() (string, error)
// MutateState mutates the state of the app.
MutateState(f func(*State))
// CopyState returns a copy of the a state.
CopyState() State
// PushAddon pushes a widget to the addon stack.
PushAddon(w tk.Widget)
// PopAddon pops the last widget from the addon stack. If the widget
// implements interface{ Dismiss() }, the Dismiss method is called
// first. This method does nothing if the addon stack is empty.
PopAddon()
// ActiveWidget returns the currently active widget. If the addon stack is
// non-empty, it returns the last addon. Otherwise it returns the main code
// area widget.
ActiveWidget() tk.Widget
// FocusedWidget returns the currently focused widget. It is searched like
// ActiveWidget, but skips widgets that implement interface{ Focus() bool }
// and return false when .Focus() is called.
FocusedWidget() tk.Widget
// CommitEOF causes the main loop to exit with EOF. If this method is called
// when an event is being handled, the main loop will exit after the handler
// returns.
CommitEOF()
// CommitCode causes the main loop to exit with the current code content. If
// this method is called when an event is being handled, the main loop will
// exit after the handler returns.
CommitCode()
// Redraw requests a redraw. It never blocks and can be called regardless of
// whether the App is active or not.
Redraw()
// RedrawFull requests a full redraw. It never blocks and can be called
// regardless of whether the App is active or not.
RedrawFull()
// Notify adds a note and requests a redraw.
Notify(note ui.Text)
}
type app struct {
loop *loop
reqRead chan struct{}
TTY TTY
MaxHeight func() int
RPromptPersistent func() bool
BeforeReadline []func()
AfterReadline []func(string)
Highlighter Highlighter
Prompt Prompt
RPrompt Prompt
GlobalBindings tk.Bindings
StateMutex sync.RWMutex
State State
codeArea tk.CodeArea
}
// State represents mutable state of an App.
type State struct {
// Notes that have been added since the last redraw.
Notes []ui.Text
// The addon stack. All widgets are shown under the codearea widget. The
// last widget handles terminal events.
Addons []tk.Widget
}
// NewApp creates a new App from the given specification.
func NewApp(spec AppSpec) App {
lp := newLoop()
a := app{
loop: lp,
TTY: spec.TTY,
MaxHeight: spec.MaxHeight,
RPromptPersistent: spec.RPromptPersistent,
BeforeReadline: spec.BeforeReadline,
AfterReadline: spec.AfterReadline,
Highlighter: spec.Highlighter,
Prompt: spec.Prompt,
RPrompt: spec.RPrompt,
GlobalBindings: spec.GlobalBindings,
State: spec.State,
}
if a.TTY == nil {
a.TTY = NewTTY(os.Stdin, os.Stderr)
}
if a.MaxHeight == nil {
a.MaxHeight = func() int { return -1 }
}
if a.RPromptPersistent == nil {
a.RPromptPersistent = func() bool { return false }
}
if a.Highlighter == nil {
a.Highlighter = dummyHighlighter{}
}
if a.Prompt == nil {
a.Prompt = NewConstPrompt(nil)
}
if a.RPrompt == nil {
a.RPrompt = NewConstPrompt(nil)
}
if a.GlobalBindings == nil {
a.GlobalBindings = tk.DummyBindings{}
}
lp.HandleCb(a.handle)
lp.RedrawCb(a.redraw)
a.codeArea = tk.NewCodeArea(tk.CodeAreaSpec{
Bindings: spec.CodeAreaBindings,
Highlighter: a.Highlighter.Get,
Prompt: a.Prompt.Get,
RPrompt: a.RPrompt.Get,
QuotePaste: spec.QuotePaste,
OnSubmit: a.CommitCode,
State: spec.CodeAreaState,
SimpleAbbreviations: spec.SimpleAbbreviations,
CommandAbbreviations: spec.CommandAbbreviations,
SmallWordAbbreviations: spec.SmallWordAbbreviations,
})
return &a
}
func (a *app) MutateState(f func(*State)) {
a.StateMutex.Lock()
defer a.StateMutex.Unlock()
f(&a.State)
}
func (a *app) CopyState() State {
a.StateMutex.RLock()
defer a.StateMutex.RUnlock()
return State{
append([]ui.Text(nil), a.State.Notes...),
append([]tk.Widget(nil), a.State.Addons...),
}
}
type dismisser interface {
Dismiss()
}
func (a *app) PushAddon(w tk.Widget) {
a.StateMutex.Lock()
defer a.StateMutex.Unlock()
a.State.Addons = append(a.State.Addons, w)
}
func (a *app) PopAddon() {
a.StateMutex.Lock()
defer a.StateMutex.Unlock()
if len(a.State.Addons) == 0 {
return
}
if d, ok := a.State.Addons[len(a.State.Addons)-1].(dismisser); ok {
d.Dismiss()
}
a.State.Addons = a.State.Addons[:len(a.State.Addons)-1]
}
func (a *app) ActiveWidget() tk.Widget {
a.StateMutex.Lock()
defer a.StateMutex.Unlock()
if len(a.State.Addons) > 0 {
return a.State.Addons[len(a.State.Addons)-1]
}
return a.codeArea
}
func (a *app) FocusedWidget() tk.Widget {
a.StateMutex.Lock()
defer a.StateMutex.Unlock()
addons := a.State.Addons
for i := len(addons) - 1; i >= 0; i-- {
if hasFocus(addons[i]) {
return addons[i]
}
}
return a.codeArea
}
func (a *app) resetAllStates() {
a.MutateState(func(s *State) { *s = State{} })
a.codeArea.MutateState(
func(s *tk.CodeAreaState) { *s = tk.CodeAreaState{} })
}
func (a *app) handle(e event) {
switch e := e.(type) {
case os.Signal:
switch e {
case syscall.SIGHUP:
a.loop.Return("", io.EOF)
case syscall.SIGINT:
a.resetAllStates()
a.triggerPrompts(true)
case sys.SIGWINCH:
a.RedrawFull()
}
case term.Event:
target := a.ActiveWidget()
handled := target.Handle(e)
if !handled {
handled = a.GlobalBindings.Handle(target, e)
}
if !handled {
if k, ok := e.(term.KeyEvent); ok {
a.Notify(ui.T("Unbound key: " + ui.Key(k).String()))
}
}
if !a.loop.HasReturned() {
a.triggerPrompts(false)
a.reqRead <- struct{}{}
}
}
}
func (a *app) triggerPrompts(force bool) {
a.Prompt.Trigger(force)
a.RPrompt.Trigger(force)
}
func (a *app) redraw(flag redrawFlag) {
// Get the dimensions available.
height, width := a.TTY.Size()
if maxHeight := a.MaxHeight(); maxHeight > 0 && maxHeight < height {
height = maxHeight
}
var notes []ui.Text
var addons []tk.Widget
a.MutateState(func(s *State) {
notes = s.Notes
s.Notes = nil
addons = append([]tk.Widget(nil), s.Addons...)
})
bufNotes := renderNotes(notes, width)
isFinalRedraw := flag&finalRedraw != 0
if isFinalRedraw {
hideRPrompt := !a.RPromptPersistent()
a.codeArea.MutateState(func(s *tk.CodeAreaState) {
s.HideTips = true
s.HideRPrompt = hideRPrompt
})
bufMain := renderApp([]tk.Widget{a.codeArea /* no addon */}, width, height)
a.codeArea.MutateState(func(s *tk.CodeAreaState) {
s.HideTips = false
s.HideRPrompt = false
})
// Insert a newline after the buffer and position the cursor there.
bufMain.Extend(term.NewBuffer(width), true)
a.TTY.UpdateBuffer(bufNotes, bufMain, flag&fullRedraw != 0)
a.TTY.ResetBuffer()
} else {
bufMain := renderApp(append([]tk.Widget{a.codeArea}, addons...), width, height)
a.TTY.UpdateBuffer(bufNotes, bufMain, flag&fullRedraw != 0)
}
}
// Renders notes. This does not respect height so that overflow notes end up in
// the scrollback buffer.
func renderNotes(notes []ui.Text, width int) *term.Buffer {
if len(notes) == 0 {
return nil
}
bb := term.NewBufferBuilder(width)
for i, note := range notes {
if i > 0 {
bb.Newline()
}
bb.WriteStyled(note)
}
return bb.Buffer()
}
// Renders the codearea, and uses the rest of the height for the listing.
func renderApp(widgets []tk.Widget, width, height int) *term.Buffer {
heights, focus := distributeHeight(widgets, width, height)
var buf *term.Buffer
for i, w := range widgets {
if heights[i] == 0 {
continue
}
buf2 := w.Render(width, heights[i])
if buf == nil {
buf = buf2
} else {
buf.Extend(buf2, i == focus)
}
}
return buf
}
// Distributes the height among all the widgets. Returns the height for each
// widget, and the index of the widget currently focused.
func distributeHeight(widgets []tk.Widget, width, height int) ([]int, int) {
var focus int
for i, w := range widgets {
if hasFocus(w) {
focus = i
}
}
n := len(widgets)
heights := make([]int, n)
if height <= n {
// Not enough (or just enough) height to render every widget with a
// height of 1.
remain := height
// Start from the focused widget, and extend downwards as much as
// possible.
for i := focus; i < n && remain > 0; i++ {
heights[i] = 1
remain--
}
// If there is still space remaining, start from the focused widget
// again, and extend upwards as much as possible.
for i := focus - 1; i >= 0 && remain > 0; i-- {
heights[i] = 1
remain--
}
return heights, focus
}
maxHeights := make([]int, n)
for i, w := range widgets {
maxHeights[i] = w.MaxHeight(width, height)
}
// The algorithm below achieves the following goals:
//
// 1. If maxHeights[u] > maxHeights[v], heights[u] >= heights[v];
//
// 2. While achieving goal 1, have as many widgets s.t. heights[u] ==
// maxHeights[u].
//
// This is done by allocating the height among the widgets following an
// non-decreasing order of maxHeights. At each step:
//
// - If it's possible to allocate maxHeights[u] to all remaining widgets,
// then allocate maxHeights[u] to widget u;
//
// - If not, allocate the remaining budget evenly - rounding down at each
// step, so the widgets with smaller maxHeights gets smaller heights.
// TODO: Add a test for this.
indices := make([]int, n)
for i := range indices {
indices[i] = i
}
sort.Slice(indices, func(i, j int) bool {
return maxHeights[indices[i]] < maxHeights[indices[j]]
})
remain := height
for rank, idx := range indices {
if remain >= maxHeights[idx]*(n-rank) {
heights[idx] = maxHeights[idx]
} else {
heights[idx] = remain / (n - rank)
}
remain -= heights[idx]
}
return heights, focus
}
func hasFocus(w any) bool {
if f, ok := w.(interface{ Focus() bool }); ok {
return f.Focus()
}
return true
}
func (a *app) ReadCode() (string, error) {
for _, f := range a.BeforeReadline {
f()
}
defer func() {
content := a.codeArea.CopyState().Buffer.Content
for _, f := range a.AfterReadline {
f(content)
}
a.resetAllStates()
}()
restore, err := a.TTY.Setup()
if err != nil {
return "", err
}
defer restore()
var wg sync.WaitGroup
defer wg.Wait()
// Relay input events.
a.reqRead = make(chan struct{}, 1)
a.reqRead <- struct{}{}
defer close(a.reqRead)
defer a.TTY.CloseReader()
wg.Add(1)
go func() {
defer wg.Done()
for range a.reqRead {
event, err := a.TTY.ReadEvent()
if err == nil {
a.loop.Input(event)
} else if err == term.ErrStopped {
return
} else if term.IsReadErrorRecoverable(err) {
a.loop.Input(term.NonfatalErrorEvent{Err: err})
} else {
a.loop.Input(term.FatalErrorEvent{Err: err})
return
}
}
}()
// Relay signals.
sigCh := a.TTY.NotifySignals()
defer a.TTY.StopSignals()
wg.Add(1)
go func() {
for sig := range sigCh {
a.loop.Input(sig)
}
wg.Done()
}()
// Relay late updates from prompt, rprompt and highlighter.
stopRelayLateUpdates := make(chan struct{})
defer close(stopRelayLateUpdates)
relayLateUpdates := func(ch <-chan struct{}) {
if ch == nil {
return
}
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ch:
a.Redraw()
case <-stopRelayLateUpdates:
return
}
}
}()
}
relayLateUpdates(a.Prompt.LateUpdates())
relayLateUpdates(a.RPrompt.LateUpdates())
relayLateUpdates(a.Highlighter.LateUpdates())
// Trigger an initial prompt update.
a.triggerPrompts(true)
return a.loop.Run()
}
func (a *app) Redraw() {
a.loop.Redraw(false)
}
func (a *app) RedrawFull() {
a.loop.Redraw(true)
}
func (a *app) CommitEOF() {
a.loop.Return("", io.EOF)
}
func (a *app) CommitCode() {
code := a.codeArea.CopyState().Buffer.Content
a.loop.Return(code, nil)
}
func (a *app) Notify(note ui.Text) {
a.MutateState(func(s *State) { s.Notes = append(s.Notes, note) })
a.Redraw()
}
elvish-0.20.1/pkg/cli/app_spec.go 0000664 0000000 0000000 00000004034 14570151573 0016546 0 ustar 00root root 0000000 0000000 package cli
import (
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/ui"
)
// AppSpec specifies the configuration and initial state for an App.
type AppSpec struct {
TTY TTY
MaxHeight func() int
RPromptPersistent func() bool
BeforeReadline []func()
AfterReadline []func(string)
Highlighter Highlighter
Prompt Prompt
RPrompt Prompt
GlobalBindings tk.Bindings
CodeAreaBindings tk.Bindings
QuotePaste func() bool
SimpleAbbreviations func(f func(abbr, full string))
CommandAbbreviations func(f func(abbr, full string))
SmallWordAbbreviations func(f func(abbr, full string))
CodeAreaState tk.CodeAreaState
State State
}
// Highlighter represents a code highlighter whose result can be delivered
// asynchronously.
type Highlighter interface {
// Get returns the highlighted code and any tips.
Get(code string) (ui.Text, []ui.Text)
// LateUpdates returns a channel for delivering late updates.
LateUpdates() <-chan struct{}
}
// A Highlighter implementation that always returns plain text.
type dummyHighlighter struct{}
func (dummyHighlighter) Get(code string) (ui.Text, []ui.Text) {
return ui.T(code), nil
}
func (dummyHighlighter) LateUpdates() <-chan struct{} { return nil }
// Prompt represents a prompt whose result can be delivered asynchronously.
type Prompt interface {
// Trigger requests a re-computation of the prompt. The force flag is set
// when triggered for the first time during a ReadCode session or after a
// SIGINT that resets the editor.
Trigger(force bool)
// Get returns the current prompt.
Get() ui.Text
// LastUpdates returns a channel for notifying late updates.
LateUpdates() <-chan struct{}
}
// NewConstPrompt returns a Prompt that always shows the given text.
func NewConstPrompt(t ui.Text) Prompt {
return constPrompt{t}
}
type constPrompt struct{ Content ui.Text }
func (constPrompt) Trigger(force bool) {}
func (p constPrompt) Get() ui.Text { return p.Content }
func (constPrompt) LateUpdates() <-chan struct{} { return nil }
elvish-0.20.1/pkg/cli/app_test.go 0000664 0000000 0000000 00000036237 14570151573 0016605 0 ustar 00root root 0000000 0000000 package cli_test
import (
"errors"
"io"
"strings"
"syscall"
"testing"
"time"
. "src.elv.sh/pkg/cli"
. "src.elv.sh/pkg/cli/clitest"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/sys"
"src.elv.sh/pkg/testutil"
"src.elv.sh/pkg/ui"
)
// Lifecycle aspects.
func TestReadCode_AbortsWhenTTYSetupReturnsError(t *testing.T) {
ttySetupErr := errors.New("a fake error")
f := Setup(WithTTY(func(tty TTYCtrl) {
tty.SetSetup(func() {}, ttySetupErr)
}))
_, err := f.Wait()
if err != ttySetupErr {
t.Errorf("ReadCode returns error %v, want %v", err, ttySetupErr)
}
}
func TestReadCode_RestoresTTYBeforeReturning(t *testing.T) {
restoreCalled := 0
f := Setup(WithTTY(func(tty TTYCtrl) {
tty.SetSetup(func() { restoreCalled++ }, nil)
}))
f.Stop()
if restoreCalled != 1 {
t.Errorf("Restore callback called %d times, want once", restoreCalled)
}
}
func TestReadCode_ResetsStateBeforeReturning(t *testing.T) {
f := Setup(WithSpec(func(spec *AppSpec) {
spec.CodeAreaState.Buffer.Content = "some code"
}))
f.Stop()
if code := f.App.ActiveWidget().(tk.CodeArea).CopyState().Buffer; code != (tk.CodeBuffer{}) {
t.Errorf("Editor state has CodeBuffer %v, want empty", code)
}
}
func TestReadCode_CallsBeforeReadline(t *testing.T) {
callCh := make(chan bool, 1)
f := Setup(WithSpec(func(spec *AppSpec) {
spec.BeforeReadline = []func(){func() { callCh <- true }}
}))
defer f.Stop()
select {
case <-callCh:
// OK, do nothing.
case <-time.After(time.Second):
t.Errorf("BeforeReadline not called")
}
}
func TestReadCode_CallsBeforeReadlineBeforePromptTrigger(t *testing.T) {
callCh := make(chan string, 2)
f := Setup(WithSpec(func(spec *AppSpec) {
spec.BeforeReadline = []func(){func() { callCh <- "hook" }}
spec.Prompt = testPrompt{trigger: func(bool) { callCh <- "prompt" }}
}))
defer f.Stop()
if first := <-callCh; first != "hook" {
t.Errorf("BeforeReadline hook not called before prompt trigger")
}
}
func TestReadCode_CallsAfterReadline(t *testing.T) {
callCh := make(chan string, 1)
f := Setup(WithSpec(func(spec *AppSpec) {
spec.AfterReadline = []func(string){func(s string) { callCh <- s }}
}))
feedInput(f.TTY, "abc\n")
f.Wait()
select {
case calledWith := <-callCh:
wantCalledWith := "abc"
if calledWith != wantCalledWith {
t.Errorf("AfterReadline hook called with %v, want %v",
calledWith, wantCalledWith)
}
case <-time.After(time.Second):
t.Errorf("AfterReadline not called")
}
}
func TestReadCode_FinalRedraw(t *testing.T) {
f := Setup(WithSpec(func(spec *AppSpec) {
spec.CodeAreaState.Buffer.Content = "code"
spec.State.Addons = []tk.Widget{tk.Label{Content: ui.T("addon")}}
}))
// Wait until the stable state.
wantBuf := bb().
Write("code").
Newline().SetDotHere().Write("addon").Buffer()
f.TTY.TestBuffer(t, wantBuf)
f.Stop()
// Final redraw hides the addon, and puts the cursor on a new line.
wantFinalBuf := bb().
Write("code").Newline().SetDotHere().Buffer()
f.TTY.TestBuffer(t, wantFinalBuf)
}
// Signals.
func TestReadCode_ReturnsEOFOnSIGHUP(t *testing.T) {
f := Setup()
f.TTY.Inject(term.K('a'))
// Wait until the initial redraw.
f.TTY.TestBuffer(t, bb().Write("a").SetDotHere().Buffer())
f.TTY.InjectSignal(syscall.SIGHUP)
_, err := f.Wait()
if err != io.EOF {
t.Errorf("want ReadCode to return io.EOF on SIGHUP, got %v", err)
}
}
func TestReadCode_ResetsStateOnSIGINT(t *testing.T) {
f := Setup()
defer f.Stop()
// Ensure that the terminal shows an non-empty state.
feedInput(f.TTY, "code")
f.TTY.TestBuffer(t, bb().Write("code").SetDotHere().Buffer())
f.TTY.InjectSignal(syscall.SIGINT)
// Verify that the state has now reset.
f.TTY.TestBuffer(t, bb().Buffer())
}
func TestReadCode_RedrawsOnSIGWINCH(t *testing.T) {
f := Setup()
defer f.Stop()
// Ensure that the terminal shows the input with the initial width.
feedInput(f.TTY, "1234567890")
f.TTY.TestBuffer(t, bb().Write("1234567890").SetDotHere().Buffer())
// Emulate a window size change.
f.TTY.SetSize(24, 4)
f.TTY.InjectSignal(sys.SIGWINCH)
// Test that the editor has redrawn using the new width.
f.TTY.TestBuffer(t, term.NewBufferBuilder(4).
Write("1234567890").SetDotHere().Buffer())
}
// Code area.
func TestReadCode_LetsCodeAreaHandleEvents(t *testing.T) {
f := Setup()
defer f.Stop()
feedInput(f.TTY, "code")
f.TTY.TestBuffer(t, bb().Write("code").SetDotHere().Buffer())
}
func TestReadCode_ShowsHighlightedCode(t *testing.T) {
f := Setup(withHighlighter(
testHighlighter{
get: func(code string) (ui.Text, []ui.Text) {
return ui.T(code, ui.FgRed), nil
},
}))
defer f.Stop()
feedInput(f.TTY, "code")
wantBuf := bb().Write("code", ui.FgRed).SetDotHere().Buffer()
f.TTY.TestBuffer(t, wantBuf)
}
func TestReadCode_ShowsErrorsFromHighlighter_ExceptInFinalRedraw(t *testing.T) {
f := Setup(withHighlighter(
testHighlighter{
get: func(code string) (ui.Text, []ui.Text) {
tips := []ui.Text{ui.T("ERR 1"), ui.T("ERR 2")}
return ui.T(code), tips
},
}))
defer f.Stop()
feedInput(f.TTY, "code")
wantBuf := bb().
Write("code").SetDotHere().Newline().
Write("ERR 1").Newline().
Write("ERR 2").Buffer()
f.TTY.TestBuffer(t, wantBuf)
feedInput(f.TTY, "\n")
f.TestTTY(t, "code", "\n", term.DotHere)
}
func TestReadCode_RedrawsOnLateUpdateFromHighlighter(t *testing.T) {
var styling ui.Styling
hl := testHighlighter{
get: func(code string) (ui.Text, []ui.Text) {
return ui.T(code, styling), nil
},
lateUpdates: make(chan struct{}),
}
f := Setup(withHighlighter(hl))
defer f.Stop()
feedInput(f.TTY, "code")
f.TTY.TestBuffer(t, bb().Write("code").SetDotHere().Buffer())
styling = ui.FgRed
hl.lateUpdates <- struct{}{}
f.TTY.TestBuffer(t, bb().Write("code", ui.FgRed).SetDotHere().Buffer())
}
func withHighlighter(hl Highlighter) func(*AppSpec, TTYCtrl) {
return WithSpec(func(spec *AppSpec) { spec.Highlighter = hl })
}
func TestReadCode_ShowsPrompt(t *testing.T) {
f := Setup(WithSpec(func(spec *AppSpec) {
spec.Prompt = NewConstPrompt(ui.T("> "))
}))
defer f.Stop()
f.TTY.Inject(term.K('a'))
f.TTY.TestBuffer(t, bb().Write("> a").SetDotHere().Buffer())
}
func TestReadCode_CallsPromptTrigger(t *testing.T) {
triggerCh := make(chan bool, 1)
f := Setup(WithSpec(func(spec *AppSpec) {
spec.Prompt = testPrompt{trigger: func(bool) { triggerCh <- true }}
}))
defer f.Stop()
select {
case <-triggerCh:
// Good, test passes
case <-time.After(time.Second):
t.Errorf("Trigger not called within 1s")
}
}
func TestReadCode_RedrawsOnLateUpdateFromPrompt(t *testing.T) {
promptContent := "old"
prompt := testPrompt{
get: func() ui.Text { return ui.T(promptContent) },
lateUpdates: make(chan struct{}),
}
f := Setup(WithSpec(func(spec *AppSpec) { spec.Prompt = prompt }))
defer f.Stop()
// Wait until old prompt is rendered
f.TTY.TestBuffer(t, bb().Write("old").SetDotHere().Buffer())
promptContent = "new"
prompt.lateUpdates <- struct{}{}
f.TTY.TestBuffer(t, bb().Write("new").SetDotHere().Buffer())
}
func TestReadCode_ShowsRPrompt(t *testing.T) {
f := Setup(WithSpec(func(spec *AppSpec) {
spec.RPrompt = NewConstPrompt(ui.T("R"))
}))
defer f.Stop()
f.TTY.Inject(term.K('a'))
wantBuf := bb().
Write("a").SetDotHere().
Write(strings.Repeat(" ", FakeTTYWidth-2)).
Write("R").Buffer()
f.TTY.TestBuffer(t, wantBuf)
}
func TestReadCode_ShowsRPromptInFinalRedrawIfPersistent(t *testing.T) {
f := Setup(WithSpec(func(spec *AppSpec) {
spec.CodeAreaState.Buffer.Content = "code"
spec.RPrompt = NewConstPrompt(ui.T("R"))
spec.RPromptPersistent = func() bool { return true }
}))
defer f.Stop()
f.TTY.Inject(term.K('\n'))
wantBuf := bb().
Write("code" + strings.Repeat(" ", FakeTTYWidth-5) + "R").
Newline().SetDotHere(). // cursor on newline in final redraw
Buffer()
f.TTY.TestBuffer(t, wantBuf)
}
func TestReadCode_HidesRPromptInFinalRedrawIfNotPersistent(t *testing.T) {
f := Setup(WithSpec(func(spec *AppSpec) {
spec.CodeAreaState.Buffer.Content = "code"
spec.RPrompt = NewConstPrompt(ui.T("R"))
spec.RPromptPersistent = func() bool { return false }
}))
defer f.Stop()
f.TTY.Inject(term.K('\n'))
wantBuf := bb().
Write("code"). // no rprompt
Newline().SetDotHere(). // cursor on newline in final redraw
Buffer()
f.TTY.TestBuffer(t, wantBuf)
}
// Addon.
func TestReadCode_LetsLastWidgetHandleEvents(t *testing.T) {
f := Setup(WithSpec(func(spec *AppSpec) {
spec.State.Addons = []tk.Widget{
tk.NewCodeArea(tk.CodeAreaSpec{
Prompt: func() ui.Text { return ui.T("addon1> ") },
}),
tk.NewCodeArea(tk.CodeAreaSpec{
Prompt: func() ui.Text { return ui.T("addon2> ") },
}),
}
}))
defer f.Stop()
feedInput(f.TTY, "input")
wantBuf := bb().Newline(). // empty main code area
Write("addon1> ").Newline(). // addon1 did not handle inputs
Write("addon2> input").SetDotHere(). // addon2 handled inputs
Buffer()
f.TTY.TestBuffer(t, wantBuf)
}
func TestReadCode_PutsCursorOnLastWidgetWithFocus(t *testing.T) {
f := Setup(WithSpec(func(spec *AppSpec) {
spec.State.Addons = []tk.Widget{
testAddon{tk.Label{Content: ui.T("addon1> ")}, true},
testAddon{tk.Label{Content: ui.T("addon2> ")}, false},
}
}))
defer f.Stop()
f.TestTTY(t, "\n", // main code area is empty
term.DotHere, "addon1> ", "\n", // addon 1 has focus
"addon2> ", // addon 2 has no focus
)
}
func TestPushAddonPopAddon(t *testing.T) {
f := Setup()
defer f.Stop()
f.TestTTY(t /* nothing */)
f.App.PushAddon(tk.Label{Content: ui.T("addon1> ")})
f.App.Redraw()
f.TestTTY(t, "\n",
term.DotHere, "addon1> ")
f.App.PushAddon(tk.Label{Content: ui.T("addon2> ")})
f.App.Redraw()
f.TestTTY(t, "\n",
"addon1> \n",
term.DotHere, "addon2> ")
f.App.PopAddon()
f.App.Redraw()
f.TestTTY(t, "\n",
term.DotHere, "addon1> ")
f.App.PopAddon()
f.App.Redraw()
f.TestTTY(t /* nothing */)
// Popping addon when there is no addon does nothing
f.App.PopAddon()
// Add something to the codearea to ensure that we're not just looking at
// the previous buffer
f.TTY.Inject(term.K(' '))
f.TestTTY(t, " ", term.DotHere)
}
func TestReadCode_HidesAddonsWhenNotEnoughSpace(t *testing.T) {
f := Setup(
func(spec *AppSpec, tty TTYCtrl) {
spec.State.Addons = []tk.Widget{
tk.Label{Content: ui.T("addon1> ")},
tk.Label{Content: ui.T("addon2> ")}, // no space for this
}
tty.SetSize(2, 40)
})
defer f.Stop()
f.TestTTY(t,
"addon1> \n",
term.DotHere, "addon2> ")
}
type testAddon struct {
tk.Label
focus bool
}
func (a testAddon) Focus() bool { return a.focus }
// Event handling.
func TestReadCode_UsesGlobalBindingsWithCodeAreaTarget(t *testing.T) {
testGlobalBindings(t, nil)
}
func TestReadCode_UsesGlobalBindingsWithAddonTarget(t *testing.T) {
testGlobalBindings(t, []tk.Widget{tk.Empty{}})
}
func testGlobalBindings(t *testing.T, addons []tk.Widget) {
gotWidgetCh := make(chan tk.Widget, 1)
f := Setup(WithSpec(func(spec *AppSpec) {
spec.GlobalBindings = tk.MapBindings{
term.K('X', ui.Ctrl): func(w tk.Widget) {
gotWidgetCh <- w
},
}
spec.State.Addons = addons
}))
defer f.Stop()
f.TTY.Inject(term.K('X', ui.Ctrl))
select {
case gotWidget := <-gotWidgetCh:
if gotWidget != f.App.ActiveWidget() {
t.Error("global binding not called with the active widget")
}
case <-time.After(testutil.Scaled(100 * time.Millisecond)):
t.Error("global binding not called")
}
}
func TestReadCode_DoesNotUseGlobalBindingsIfHandledByWidget(t *testing.T) {
f := Setup(WithSpec(func(spec *AppSpec) {
spec.GlobalBindings = tk.MapBindings{
term.K('a'): func(w tk.Widget) {},
}
}))
defer f.Stop()
f.TTY.Inject(term.K('a'))
// Still handled by code area instead of global binding
f.TestTTY(t, "a", term.DotHere)
}
func TestReadCode_NotifiesAboutUnboundKey(t *testing.T) {
f := Setup()
defer f.Stop()
f.TTY.Inject(term.K(ui.F1))
f.TestTTYNotes(t, "Unbound key: F1")
}
// Misc features.
func TestReadCode_TrimsBufferToMaxHeight(t *testing.T) {
f := Setup(func(spec *AppSpec, tty TTYCtrl) {
spec.MaxHeight = func() int { return 2 }
// The code needs 3 lines to completely show.
spec.CodeAreaState.Buffer.Content = strings.Repeat("a", 15)
tty.SetSize(10, 5) // Width = 5 to make it easy to test
})
defer f.Stop()
wantBuf := term.NewBufferBuilder(5).
Write(strings.Repeat("a", 10)). // Only show 2 lines due to MaxHeight.
Buffer()
f.TTY.TestBuffer(t, wantBuf)
}
func TestReadCode_ShowNotes(t *testing.T) {
// Set up with a binding where 'a' can block indefinitely. This is useful
// for testing the behavior of writing multiple notes.
inHandler := make(chan struct{})
unblock := make(chan struct{})
f := Setup(WithSpec(func(spec *AppSpec) {
spec.CodeAreaBindings = tk.MapBindings{
term.K('a'): func(tk.Widget) {
inHandler <- struct{}{}
<-unblock
},
}
}))
defer f.Stop()
// Wait until initial draw.
f.TTY.TestBuffer(t, bb().Buffer())
// Make sure that the app is blocked within an event handler.
f.TTY.Inject(term.K('a'))
<-inHandler
// Write two notes, and unblock the event handler
f.App.Notify(ui.T("note"))
f.App.Notify(ui.T("note 2"))
unblock <- struct{}{}
// Test that the note is rendered onto the notes buffer.
wantNotesBuf := bb().Write("note").Newline().Write("note 2").Buffer()
f.TTY.TestNotesBuffer(t, wantNotesBuf)
// Test that notes are flushed after being rendered.
if n := len(f.App.CopyState().Notes); n > 0 {
t.Errorf("State.Notes has %d elements after redrawing, want 0", n)
}
}
func TestReadCode_DoesNotCrashWithNilTTY(t *testing.T) {
f := Setup(WithSpec(func(spec *AppSpec) { spec.TTY = nil }))
defer f.Stop()
}
// Other properties.
func TestReadCode_DoesNotLockWithALotOfInputsWithNewlines(t *testing.T) {
// Regression test for #887
f := Setup(WithTTY(func(tty TTYCtrl) {
for i := 0; i < 1000; i++ {
tty.Inject(term.K('#'), term.K('\n'))
}
}))
terminated := make(chan struct{})
go func() {
f.Wait()
close(terminated)
}()
select {
case <-terminated:
// OK
case <-time.After(time.Second):
t.Errorf("ReadCode did not terminate within 1s")
}
}
func TestReadCode_DoesNotReadMoreEventsThanNeeded(t *testing.T) {
f := Setup()
defer f.Stop()
f.TTY.Inject(term.K('a'), term.K('\n'), term.K('b'))
code, err := f.Wait()
if code != "a" || err != nil {
t.Errorf("got (%q, %v), want (%q, nil)", code, err, "a")
}
if event := <-f.TTY.EventCh(); event != term.K('b') {
t.Errorf("got event %v, want %v", event, term.K('b'))
}
}
// Test utilities.
func bb() *term.BufferBuilder {
return term.NewBufferBuilder(FakeTTYWidth)
}
func feedInput(ttyCtrl TTYCtrl, input string) {
for _, r := range input {
ttyCtrl.Inject(term.K(r))
}
}
// A Highlighter implementation useful for testing.
type testHighlighter struct {
get func(code string) (ui.Text, []ui.Text)
lateUpdates chan struct{}
}
func (hl testHighlighter) Get(code string) (ui.Text, []ui.Text) {
return hl.get(code)
}
func (hl testHighlighter) LateUpdates() <-chan struct{} {
return hl.lateUpdates
}
// A Prompt implementation useful for testing.
type testPrompt struct {
trigger func(force bool)
get func() ui.Text
lateUpdates chan struct{}
}
func (p testPrompt) Trigger(force bool) {
if p.trigger != nil {
p.trigger(force)
}
}
func (p testPrompt) Get() ui.Text {
if p.get != nil {
return p.get()
}
return nil
}
func (p testPrompt) LateUpdates() <-chan struct{} {
return p.lateUpdates
}
elvish-0.20.1/pkg/cli/clitest/ 0000775 0000000 0000000 00000000000 14570151573 0016073 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/cli/clitest/apptest.go 0000664 0000000 0000000 00000006220 14570151573 0020102 0 ustar 00root root 0000000 0000000 // Package clitest provides utilities for testing cli.App.
package clitest
import (
"testing"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/ui"
)
// Styles defines a common stylesheet for unit tests.
var Styles = ui.RuneStylesheet{
'_': ui.Underlined,
'b': ui.Bold,
'*': ui.Stylings(ui.Bold, ui.FgWhite, ui.BgMagenta),
'+': ui.Inverse,
'/': ui.FgBlue,
'#': ui.Stylings(ui.Inverse, ui.FgBlue),
'!': ui.FgRed,
'?': ui.Stylings(ui.FgBrightWhite, ui.BgRed),
'-': ui.FgMagenta,
'X': ui.Stylings(ui.Inverse, ui.FgMagenta),
'v': ui.FgGreen,
'V': ui.Stylings(ui.Underlined, ui.FgGreen),
'$': ui.FgMagenta,
'c': ui.FgCyan, // mnemonic "Comment"
}
// Fixture is a test fixture.
type Fixture struct {
App cli.App
TTY TTYCtrl
width int
codeCh <-chan string
errCh <-chan error
}
// Setup sets up a test fixture. It contains an App whose ReadCode method has
// been started asynchronously.
func Setup(fns ...func(*cli.AppSpec, TTYCtrl)) *Fixture {
tty, ttyCtrl := NewFakeTTY()
spec := cli.AppSpec{TTY: tty}
for _, fn := range fns {
fn(&spec, ttyCtrl)
}
app := cli.NewApp(spec)
codeCh, errCh := StartReadCode(app.ReadCode)
_, width := tty.Size()
return &Fixture{app, ttyCtrl, width, codeCh, errCh}
}
// WithSpec takes a function that operates on *cli.AppSpec, and wraps it into a
// form suitable for passing to Setup.
func WithSpec(f func(*cli.AppSpec)) func(*cli.AppSpec, TTYCtrl) {
return func(spec *cli.AppSpec, _ TTYCtrl) { f(spec) }
}
// WithTTY takes a function that operates on TTYCtrl, and wraps it to a form
// suitable for passing to Setup.
func WithTTY(f func(TTYCtrl)) func(*cli.AppSpec, TTYCtrl) {
return func(_ *cli.AppSpec, tty TTYCtrl) { f(tty) }
}
// Wait waits for ReaCode to finish, and returns its return values.
func (f *Fixture) Wait() (string, error) {
return <-f.codeCh, <-f.errCh
}
// Stop stops ReadCode and waits for it to finish. If ReadCode has already been
// stopped, it is a no-op.
func (f *Fixture) Stop() {
f.App.CommitEOF()
f.Wait()
}
// MakeBuffer is a helper for building a buffer. It is equivalent to
// term.NewBufferBuilder(width of terminal).MarkLines(args...).Buffer().
func (f *Fixture) MakeBuffer(args ...any) *term.Buffer {
return term.NewBufferBuilder(f.width).MarkLines(args...).Buffer()
}
// TestTTY is equivalent to f.TTY.TestBuffer(f.MakeBuffer(args...)).
func (f *Fixture) TestTTY(t *testing.T, args ...any) {
t.Helper()
f.TTY.TestBuffer(t, f.MakeBuffer(args...))
}
// TestTTYNotes is equivalent to f.TTY.TestNotesBuffer(f.MakeBuffer(args...)).
func (f *Fixture) TestTTYNotes(t *testing.T, args ...any) {
t.Helper()
f.TTY.TestNotesBuffer(t, f.MakeBuffer(args...))
}
// StartReadCode starts the readCode function asynchronously, and returns two
// channels that deliver its return values. The two channels are closed after
// return values are delivered, so that subsequent reads will return zero values
// and not block.
func StartReadCode(readCode func() (string, error)) (<-chan string, <-chan error) {
codeCh := make(chan string, 1)
errCh := make(chan error, 1)
go func() {
code, err := readCode()
codeCh <- code
errCh <- err
close(codeCh)
close(errCh)
}()
return codeCh, errCh
}
elvish-0.20.1/pkg/cli/clitest/apptest_test.go 0000664 0000000 0000000 00000001662 14570151573 0021146 0 ustar 00root root 0000000 0000000 package clitest
import (
"testing"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/ui"
)
func TestFixture(t *testing.T) {
f := Setup(
WithSpec(func(spec *cli.AppSpec) {
spec.CodeAreaState.Buffer = tk.CodeBuffer{Content: "test", Dot: 4}
}),
WithTTY(func(tty TTYCtrl) {
tty.SetSize(20, 30) // h = 20, w = 30
}),
)
defer f.Stop()
// Verify that the functions passed to Setup have taken effect.
if f.App.ActiveWidget().(tk.CodeArea).CopyState().Buffer.Content != "test" {
t.Errorf("WithSpec did not work")
}
buf := f.MakeBuffer()
// Verify that the WithTTY function has taken effect.
if buf.Width != 30 {
t.Errorf("WithTTY did not work")
}
f.TestTTY(t, "test", term.DotHere)
f.App.Notify(ui.T("something"))
f.TestTTYNotes(t, "something")
f.App.CommitCode()
if code, err := f.Wait(); code != "test" || err != nil {
t.Errorf("Wait returned %q, %v", code, err)
}
}
elvish-0.20.1/pkg/cli/clitest/fake_tty.go 0000664 0000000 0000000 00000016725 14570151573 0020243 0 ustar 00root root 0000000 0000000 package clitest
import (
"os"
"reflect"
"sync"
"testing"
"time"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/testutil"
)
const (
// Maximum number of buffer updates FakeTTY expect to see.
fakeTTYBufferUpdates = 4096
// Maximum number of events FakeTTY produces.
fakeTTYEvents = 4096
// Maximum number of signals FakeTTY produces.
fakeTTYSignals = 4096
)
// An implementation of the cli.TTY interface that is useful in tests.
type fakeTTY struct {
setup func() (func(), error)
// Channel that StartRead returns. Can be used to inject additional events.
eventCh chan term.Event
// Whether eventCh has been closed.
eventChClosed bool
// Mutex for synchronizing writing and closing eventCh.
eventChMutex sync.Mutex
// Channel for publishing updates of the main buffer and notes buffer.
bufCh, notesBufCh chan *term.Buffer
// Records history of the main buffer and notes buffer.
bufs, notesBufs []*term.Buffer
// Mutexes for guarding bufs and notesBufs.
bufMutex sync.RWMutex
// Channel that NotifySignals returns. Can be used to inject signals.
sigCh chan os.Signal
// Argument that SetRawInput got.
raw int
// Number of times the TTY screen has been cleared, incremented in
// ClearScreen.
cleared int
sizeMutex sync.RWMutex
// Predefined sizes.
height, width int
}
// Initial size of fake TTY.
const (
FakeTTYHeight = 20
FakeTTYWidth = 50
)
// NewFakeTTY creates a new FakeTTY and a handle for controlling it. The initial
// size of the terminal is FakeTTYHeight and FakeTTYWidth.
func NewFakeTTY() (cli.TTY, TTYCtrl) {
tty := &fakeTTY{
eventCh: make(chan term.Event, fakeTTYEvents),
sigCh: make(chan os.Signal, fakeTTYSignals),
bufCh: make(chan *term.Buffer, fakeTTYBufferUpdates),
notesBufCh: make(chan *term.Buffer, fakeTTYBufferUpdates),
height: FakeTTYHeight, width: FakeTTYWidth,
}
return tty, TTYCtrl{tty}
}
// Delegates to the setup function specified using the SetSetup method of
// TTYCtrl, or return a nop function and a nil error.
func (t *fakeTTY) Setup() (func(), error) {
if t.setup == nil {
return func() {}, nil
}
return t.setup()
}
// Returns the size specified by using the SetSize method of TTYCtrl.
func (t *fakeTTY) Size() (h, w int) {
t.sizeMutex.RLock()
defer t.sizeMutex.RUnlock()
return t.height, t.width
}
// Returns next event from t.eventCh.
func (t *fakeTTY) ReadEvent() (term.Event, error) {
return <-t.eventCh, nil
}
// Records the argument.
func (t *fakeTTY) SetRawInput(n int) {
t.raw = n
}
// Closes eventCh.
func (t *fakeTTY) CloseReader() {
t.eventChMutex.Lock()
defer t.eventChMutex.Unlock()
close(t.eventCh)
t.eventChClosed = true
}
// Returns the last recorded buffer.
func (t *fakeTTY) Buffer() *term.Buffer {
t.bufMutex.RLock()
defer t.bufMutex.RUnlock()
return t.bufs[len(t.bufs)-1]
}
// Records a nil buffer.
func (t *fakeTTY) ResetBuffer() {
t.bufMutex.Lock()
defer t.bufMutex.Unlock()
t.recordBuf(nil)
}
// UpdateBuffer records a new pair of buffers, i.e. sending them to their
// respective channels and appending them to their respective slices.
func (t *fakeTTY) UpdateBuffer(bufNotes, buf *term.Buffer, _ bool) error {
t.bufMutex.Lock()
defer t.bufMutex.Unlock()
t.recordNotesBuf(bufNotes)
t.recordBuf(buf)
return nil
}
func (t *fakeTTY) HideCursor() {
}
func (t *fakeTTY) ShowCursor() {
}
func (t *fakeTTY) ClearScreen() {
t.cleared++
}
func (t *fakeTTY) NotifySignals() <-chan os.Signal { return t.sigCh }
func (t *fakeTTY) StopSignals() { close(t.sigCh) }
func (t *fakeTTY) recordBuf(buf *term.Buffer) {
t.bufs = append(t.bufs, buf)
t.bufCh <- buf
}
func (t *fakeTTY) recordNotesBuf(buf *term.Buffer) {
t.notesBufs = append(t.notesBufs, buf)
t.notesBufCh <- buf
}
// TTYCtrl is an interface for controlling a fake terminal.
type TTYCtrl struct{ *fakeTTY }
// GetTTYCtrl takes a TTY and returns a TTYCtrl and true, if the TTY is a fake
// terminal. Otherwise it returns an invalid TTYCtrl and false.
func GetTTYCtrl(t cli.TTY) (TTYCtrl, bool) {
fake, ok := t.(*fakeTTY)
return TTYCtrl{fake}, ok
}
// SetSetup sets the return values of the Setup method of the fake terminal.
func (t TTYCtrl) SetSetup(restore func(), err error) {
t.setup = func() (func(), error) {
return restore, err
}
}
// SetSize sets the size of the fake terminal.
func (t TTYCtrl) SetSize(h, w int) {
t.sizeMutex.Lock()
defer t.sizeMutex.Unlock()
t.height, t.width = h, w
}
// Inject injects events to the fake terminal.
func (t TTYCtrl) Inject(events ...term.Event) {
for _, event := range events {
t.inject(event)
}
}
func (t TTYCtrl) inject(event term.Event) {
t.eventChMutex.Lock()
defer t.eventChMutex.Unlock()
if !t.eventChClosed {
t.eventCh <- event
}
}
// EventCh returns the underlying channel for delivering events.
func (t TTYCtrl) EventCh() chan term.Event {
return t.eventCh
}
// InjectSignal injects signals.
func (t TTYCtrl) InjectSignal(sigs ...os.Signal) {
for _, sig := range sigs {
t.sigCh <- sig
}
}
// RawInput returns the argument in the last call to the SetRawInput method of
// the TTY.
func (t TTYCtrl) RawInput() int {
return t.raw
}
// ScreenCleared returns the number of times ClearScreen has been called on the
// TTY.
func (t TTYCtrl) ScreenCleared() int {
return t.cleared
}
// TestBuffer verifies that a buffer will appear within 100ms, and aborts the
// test if it doesn't.
func (t TTYCtrl) TestBuffer(tt *testing.T, b *term.Buffer) {
tt.Helper()
ok := testBuffer(b, t.bufCh)
if !ok {
tt.Logf("wanted buffer not shown:\n%s", b.TTYString())
t.bufMutex.RLock()
defer t.bufMutex.RUnlock()
lastBuf := t.LastBuffer()
tt.Logf("Last buffer: %s", lastBuf.TTYString())
if lastBuf == nil {
bufs := t.BufferHistory()
for i := len(bufs) - 1; i >= 0; i-- {
if bufs[i] != nil {
tt.Logf("Last non-nil buffer: %s", bufs[i].TTYString())
return
}
}
}
tt.FailNow()
}
}
// TestNotesBuffer verifies that a notes buffer will appear within 100ms, and
// aborts the test if it doesn't.
func (t TTYCtrl) TestNotesBuffer(tt *testing.T, b *term.Buffer) {
tt.Helper()
ok := testBuffer(b, t.notesBufCh)
if !ok {
tt.Logf("wanted notes buffer not shown:\n%s", b.TTYString())
t.bufMutex.RLock()
defer t.bufMutex.RUnlock()
bufs := t.NotesBufferHistory()
tt.Logf("There has been %d notes buffers. None-nil ones are:", len(bufs))
for i, buf := range bufs {
if buf != nil {
tt.Logf("#%d:\n%s", i, buf.TTYString())
}
}
tt.FailNow()
}
}
// BufferHistory returns a slice of all buffers that have appeared.
func (t TTYCtrl) BufferHistory() []*term.Buffer {
t.bufMutex.RLock()
defer t.bufMutex.RUnlock()
return t.bufs
}
// LastBuffer returns the last buffer that has appeared.
func (t TTYCtrl) LastBuffer() *term.Buffer {
t.bufMutex.RLock()
defer t.bufMutex.RUnlock()
if len(t.bufs) == 0 {
return nil
}
return t.bufs[len(t.bufs)-1]
}
// NotesBufferHistory returns a slice of all notes buffers that have appeared.
func (t TTYCtrl) NotesBufferHistory() []*term.Buffer {
t.bufMutex.RLock()
defer t.bufMutex.RUnlock()
return t.notesBufs
}
func (t TTYCtrl) LastNotesBuffer() *term.Buffer {
t.bufMutex.RLock()
defer t.bufMutex.RUnlock()
if len(t.notesBufs) == 0 {
return nil
}
return t.notesBufs[len(t.notesBufs)-1]
}
// Tests that an buffer appears on the channel within 100ms.
func testBuffer(want *term.Buffer, ch <-chan *term.Buffer) bool {
timeout := time.After(testutil.Scaled(100 * time.Millisecond))
for {
select {
case buf := <-ch:
if reflect.DeepEqual(buf, want) {
return true
}
case <-timeout:
return false
}
}
}
elvish-0.20.1/pkg/cli/clitest/fake_tty_test.go 0000664 0000000 0000000 00000007776 14570151573 0021310 0 ustar 00root root 0000000 0000000 package clitest
import (
"os"
"reflect"
"testing"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/term"
)
func TestFakeTTY_Setup(t *testing.T) {
tty, ttyCtrl := NewFakeTTY()
restoreCalled := 0
ttyCtrl.SetSetup(func() { restoreCalled++ }, nil)
restore, err := tty.Setup()
if err != nil {
t.Errorf("Setup -> error %v, want nil", err)
}
restore()
if restoreCalled != 1 {
t.Errorf("Setup did not return restore")
}
}
func TestFakeTTY_Size(t *testing.T) {
tty, ttyCtrl := NewFakeTTY()
ttyCtrl.SetSize(20, 30)
h, w := tty.Size()
if h != 20 || w != 30 {
t.Errorf("Size -> (%v, %v), want (20, 30)", h, w)
}
}
func TestFakeTTY_SetRawInput(t *testing.T) {
tty, ttyCtrl := NewFakeTTY()
tty.SetRawInput(2)
if raw := ttyCtrl.RawInput(); raw != 2 {
t.Errorf("RawInput() -> %v, want 2", raw)
}
}
func TestFakeTTY_Events(t *testing.T) {
tty, ttyCtrl := NewFakeTTY()
ttyCtrl.Inject(term.K('a'), term.K('b'))
if event, err := tty.ReadEvent(); event != term.K('a') || err != nil {
t.Errorf("Got (%v, %v), want (%v, nil)", event, err, term.K('a'))
}
if event := <-ttyCtrl.EventCh(); event != term.K('b') {
t.Errorf("Got event %v, want K('b')", event)
}
}
func TestFakeTTY_Signals(t *testing.T) {
tty, ttyCtrl := NewFakeTTY()
signals := tty.NotifySignals()
ttyCtrl.InjectSignal(os.Interrupt, os.Kill)
signal := <-signals
if signal != os.Interrupt {
t.Errorf("Got signal %v, want %v", signal, os.Interrupt)
}
signal = <-signals
if signal != os.Kill {
t.Errorf("Got signal %v, want %v", signal, os.Kill)
}
}
func TestFakeTTY_Buffer(t *testing.T) {
bufNotes1 := term.NewBufferBuilder(10).Write("notes 1").Buffer()
buf1 := term.NewBufferBuilder(10).Write("buf 1").Buffer()
bufNotes2 := term.NewBufferBuilder(10).Write("notes 2").Buffer()
buf2 := term.NewBufferBuilder(10).Write("buf 2").Buffer()
bufNotes3 := term.NewBufferBuilder(10).Write("notes 3").Buffer()
buf3 := term.NewBufferBuilder(10).Write("buf 3").Buffer()
tty, ttyCtrl := NewFakeTTY()
if ttyCtrl.LastNotesBuffer() != nil {
t.Errorf("LastNotesBuffer -> %v, want nil", ttyCtrl.LastNotesBuffer())
}
if ttyCtrl.LastBuffer() != nil {
t.Errorf("LastBuffer -> %v, want nil", ttyCtrl.LastBuffer())
}
tty.UpdateBuffer(bufNotes1, buf1, true)
if ttyCtrl.LastNotesBuffer() != bufNotes1 {
t.Errorf("LastBuffer -> %v, want %v", ttyCtrl.LastNotesBuffer(), bufNotes1)
}
if ttyCtrl.LastBuffer() != buf1 {
t.Errorf("LastBuffer -> %v, want %v", ttyCtrl.LastBuffer(), buf1)
}
ttyCtrl.TestBuffer(t, buf1)
ttyCtrl.TestNotesBuffer(t, bufNotes1)
tty.UpdateBuffer(bufNotes2, buf2, true)
if ttyCtrl.LastNotesBuffer() != bufNotes2 {
t.Errorf("LastBuffer -> %v, want %v", ttyCtrl.LastNotesBuffer(), bufNotes2)
}
if ttyCtrl.LastBuffer() != buf2 {
t.Errorf("LastBuffer -> %v, want %v", ttyCtrl.LastBuffer(), buf2)
}
ttyCtrl.TestBuffer(t, buf2)
ttyCtrl.TestNotesBuffer(t, bufNotes2)
// Test Test{,Notes}Buffer
tty.UpdateBuffer(bufNotes3, buf3, true)
ttyCtrl.TestBuffer(t, buf3)
ttyCtrl.TestNotesBuffer(t, bufNotes3)
// Cannot test the failure branch as that will fail the test
wantBufs := []*term.Buffer{buf1, buf2, buf3}
wantNotesBufs := []*term.Buffer{bufNotes1, bufNotes2, bufNotes3}
if !reflect.DeepEqual(ttyCtrl.BufferHistory(), wantBufs) {
t.Errorf("BufferHistory did not return {buf1, buf2}")
}
if !reflect.DeepEqual(ttyCtrl.NotesBufferHistory(), wantNotesBufs) {
t.Errorf("NotesBufferHistory did not return {bufNotes1, bufNotes2}")
}
}
func TestFakeTTY_ClearScreen(t *testing.T) {
fakeTTY, ttyCtrl := NewFakeTTY()
for i := 0; i < 5; i++ {
if cleared := ttyCtrl.ScreenCleared(); cleared != i {
t.Errorf("ScreenCleared -> %v, want %v", cleared, i)
}
fakeTTY.ClearScreen()
}
}
func TestGetTTYCtrl_FakeTTY(t *testing.T) {
fakeTTY, ttyCtrl := NewFakeTTY()
if got, ok := GetTTYCtrl(fakeTTY); got != ttyCtrl || !ok {
t.Errorf("-> %v, %v, want %v, %v", got, ok, ttyCtrl, true)
}
}
func TestGetTTYCtrl_RealTTY(t *testing.T) {
realTTY := cli.NewTTY(os.Stdin, os.Stderr)
if _, ok := GetTTYCtrl(realTTY); ok {
t.Errorf("-> _, true, want _, false")
}
}
elvish-0.20.1/pkg/cli/examples/ 0000775 0000000 0000000 00000000000 14570151573 0016242 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/cli/examples/e3bc/ 0000775 0000000 0000000 00000000000 14570151573 0017056 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/cli/examples/e3bc/bc/ 0000775 0000000 0000000 00000000000 14570151573 0017442 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/cli/examples/e3bc/bc/bc.go 0000664 0000000 0000000 00000001740 14570151573 0020357 0 ustar 00root root 0000000 0000000 package bc
import (
"io"
"log"
"os"
"os/exec"
)
type Bc interface {
Exec(string) error
Quit()
}
type bc struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
}
func Start() Bc {
cmd := exec.Command("bc")
stdin, err := cmd.StdinPipe()
if err != nil {
log.Fatal(err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
log.Fatal(err)
}
cmd.Stderr = os.Stderr
cmd.Start()
return &bc{cmd, stdin, stdout}
}
// TODO: Use a more robust marker
var inputSuffix = []byte("\n\"\x04\"\n")
func (bc *bc) Exec(code string) error {
bc.stdin.Write([]byte(code))
bc.stdin.Write(inputSuffix)
for {
b, err := readByte(bc.stdout)
if err != nil {
return err
}
if b == 0x04 {
break
}
os.Stdout.Write([]byte{b})
}
return nil
}
func readByte(r io.Reader) (byte, error) {
var buf [1]byte
_, err := r.Read(buf[:])
if err != nil {
return 0, err
}
return buf[0], nil
}
func (bc *bc) Quit() {
bc.stdin.Close()
bc.cmd.Wait()
bc.stdout.Close()
}
elvish-0.20.1/pkg/cli/examples/e3bc/completion.go 0000664 0000000 0000000 00000001137 14570151573 0021560 0 ustar 00root root 0000000 0000000 package main
import (
"src.elv.sh/pkg/cli/modes"
"src.elv.sh/pkg/ui"
)
var items = []string{
// Functions
"length(", "read(", "scale(", "sqrt(",
// Functions in math library
"s(", "c(", "a(", "l(", "e(", "j(",
// Statements
"print ", "if ", "while (", "for (",
"break", "continue", "halt", "return", "return (",
// Pseudo statements
"limits", "quit", "warranty",
}
func candidates() []modes.CompletionItem {
candidates := make([]modes.CompletionItem, len(items))
for i, item := range items {
candidates[i] = modes.CompletionItem{ToShow: ui.T(item), ToInsert: item}
}
return candidates
}
elvish-0.20.1/pkg/cli/examples/e3bc/main.go 0000664 0000000 0000000 00000003301 14570151573 0020326 0 ustar 00root root 0000000 0000000 // Command e3bc ("Elvish-editor-enhanced bc") is a wrapper for the bc command
// that uses Elvish's cli library for an enhanced CLI experience.
package main
import (
"fmt"
"io"
"unicode"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/examples/e3bc/bc"
"src.elv.sh/pkg/cli/modes"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/diag"
"src.elv.sh/pkg/ui"
)
// A highlighter for bc code. Currently this just makes all digits green.
//
// TODO: Highlight more syntax of bc.
type highlighter struct{}
func (highlighter) Get(code string) (ui.Text, []ui.Text) {
t := ui.Text{}
for _, r := range code {
var style ui.Styling
if unicode.IsDigit(r) {
style = ui.FgGreen
}
t = append(t, ui.T(string(r), style)...)
}
return t, nil
}
func (highlighter) LateUpdates() <-chan struct{} { return nil }
func main() {
var app cli.App
app = cli.NewApp(cli.AppSpec{
Prompt: cli.NewConstPrompt(ui.T("bc> ")),
Highlighter: highlighter{},
CodeAreaBindings: tk.MapBindings{
term.K('D', ui.Ctrl): func(tk.Widget) { app.CommitEOF() },
term.K(ui.Tab): func(w tk.Widget) {
codearea := w.(tk.CodeArea)
if codearea.CopyState().Buffer.Content != "" {
// Only complete with an empty buffer
return
}
w, err := modes.NewCompletion(app, modes.CompletionSpec{
Replace: diag.Ranging{From: 0, To: 0}, Items: candidates(),
})
if err == nil {
app.PushAddon(w)
}
},
},
GlobalBindings: tk.MapBindings{
term.K('[', ui.Ctrl): func(tk.Widget) { app.PopAddon() },
},
})
bc := bc.Start()
defer bc.Quit()
for {
code, err := app.ReadCode()
if err != nil {
if err != io.EOF {
fmt.Println("error:", err)
}
break
}
bc.Exec(code)
}
}
elvish-0.20.1/pkg/cli/examples/nav/ 0000775 0000000 0000000 00000000000 14570151573 0017026 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/cli/examples/nav/main.go 0000664 0000000 0000000 00000001003 14570151573 0020273 0 ustar 00root root 0000000 0000000 // Command nav runs the navigation mode of the line editor.
package main
import (
"fmt"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/modes"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
)
func main() {
app := cli.NewApp(cli.AppSpec{})
w, _ := modes.NewNavigation(app, modes.NavigationSpec{
Bindings: tk.MapBindings{
term.K('x'): func(tk.Widget) { app.CommitCode() },
},
})
app.PushAddon(w)
code, err := app.ReadCode()
fmt.Println("code:", code)
if err != nil {
fmt.Println("err", err)
}
}
elvish-0.20.1/pkg/cli/examples/widget/ 0000775 0000000 0000000 00000000000 14570151573 0017525 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/cli/examples/widget/main.go 0000664 0000000 0000000 00000003077 14570151573 0021007 0 ustar 00root root 0000000 0000000 // Command widget allows manually testing a single widget.
package main
import (
"flag"
"fmt"
"os"
"strconv"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/ui"
)
var (
maxHeight = flag.Int("max-height", 10, "maximum height")
horizontal = flag.Bool("horizontal", false, "use horizontal listbox layout")
)
func makeWidget() tk.Widget {
items := tk.TestItems{Prefix: "list item "}
w := tk.NewComboBox(tk.ComboBoxSpec{
CodeArea: tk.CodeAreaSpec{
Prompt: func() ui.Text {
return ui.Concat(ui.T(" NUMBER ", ui.Bold, ui.BgMagenta), ui.T(" "))
},
},
ListBox: tk.ListBoxSpec{
State: tk.ListBoxState{Items: &items},
Placeholder: ui.T("(no items)"),
Horizontal: *horizontal,
},
OnFilter: func(w tk.ComboBox, filter string) {
if n, err := strconv.Atoi(filter); err == nil {
items.NItems = n
}
},
})
return w
}
func main() {
flag.Parse()
widget := makeWidget()
tty := cli.NewTTY(os.Stdin, os.Stderr)
restore, err := tty.Setup()
if err != nil {
fmt.Fprintln(os.Stderr, err)
return
}
defer restore()
defer tty.CloseReader()
for {
h, w := tty.Size()
if h > *maxHeight {
h = *maxHeight
}
tty.UpdateBuffer(nil, widget.Render(w, h), false)
event, err := tty.ReadEvent()
if err != nil {
errBuf := term.NewBufferBuilder(w).Write(err.Error(), ui.FgRed).Buffer()
tty.UpdateBuffer(nil, errBuf, true)
break
}
handled := widget.Handle(event)
if !handled && event == term.K('D', ui.Ctrl) {
tty.UpdateBuffer(nil, term.NewBufferBuilder(w).Buffer(), true)
break
}
}
}
elvish-0.20.1/pkg/cli/examples/win_tty/ 0000775 0000000 0000000 00000000000 14570151573 0017737 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/cli/examples/win_tty/main_windows.go 0000664 0000000 0000000 00000004362 14570151573 0022771 0 ustar 00root root 0000000 0000000 package main
import (
"log"
"os"
"strings"
"unicode"
"golang.org/x/sys/windows"
"src.elv.sh/pkg/sys/ewindows"
)
func main() {
restore := setup(os.Stdin, os.Stdout)
defer restore()
log.Println("ready")
console, err := windows.GetStdHandle(windows.STD_INPUT_HANDLE)
if err != nil {
log.Fatalf("GetStdHandle(STD_INPUT_HANDLE): %v", err)
}
for {
var buf [1]ewindows.InputRecord
nr, err := ewindows.ReadConsoleInput(console, buf[:])
if nr == 0 {
log.Fatal("no event read")
}
if err != nil {
log.Fatal(err)
}
event := buf[0].GetEvent()
switch event := event.(type) {
case *ewindows.KeyEvent:
typ := "up"
if event.BKeyDown != 0 {
typ = "down"
}
r := rune(event.UChar[0]) + rune(event.UChar[1])<<8
rs := "(" + string(r) + ")"
if unicode.IsControl(r) {
rs = " "
}
var mods []string
testMod := func(mask uint32, name string) {
if event.DwControlKeyState&mask != 0 {
mods = append(mods, name)
}
}
testMod(0x1, "right alt")
testMod(0x2, "left alt")
testMod(0x4, "right ctrl")
testMod(0x8, "left ctrl")
testMod(0x10, "shift")
// testMod(0x20, "numslock")
testMod(0x40, "scrolllock")
testMod(0x80, "capslock")
testMod(0x100, "enhanced")
log.Printf("%4s, key code = %02x, char = %04x %s, mods = %s\n",
typ, event.WVirtualKeyCode, r, rs, strings.Join(mods, ", "))
}
}
}
const (
wantedInMode = windows.ENABLE_WINDOW_INPUT |
windows.ENABLE_MOUSE_INPUT | windows.ENABLE_PROCESSED_INPUT
wantedOutMode = windows.ENABLE_PROCESSED_OUTPUT |
windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING
)
func setup(in, out *os.File) func() {
hIn := windows.Handle(in.Fd())
hOut := windows.Handle(out.Fd())
var oldInMode, oldOutMode uint32
err := windows.GetConsoleMode(hIn, &oldInMode)
if err != nil {
log.Fatal(err)
}
err = windows.GetConsoleMode(hOut, &oldOutMode)
if err != nil {
log.Fatal(err)
}
err = windows.SetConsoleMode(hIn, wantedInMode)
if err != nil {
log.Fatal(err)
}
err = windows.SetConsoleMode(hOut, wantedOutMode)
if err != nil {
log.Fatal(err)
}
return func() {
err := windows.SetConsoleMode(hIn, oldInMode)
if err != nil {
log.Fatal(err)
}
err = windows.SetConsoleMode(hOut, oldOutMode)
if err != nil {
log.Fatal(err)
}
}
}
elvish-0.20.1/pkg/cli/histutil/ 0000775 0000000 0000000 00000000000 14570151573 0016271 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/cli/histutil/db.go 0000664 0000000 0000000 00000000552 14570151573 0017207 0 ustar 00root root 0000000 0000000 package histutil
import (
"src.elv.sh/pkg/store/storedefs"
)
// DB is the interface of the storage database.
type DB interface {
NextCmdSeq() (int, error)
AddCmd(cmd string) (int, error)
CmdsWithSeq(from, upto int) ([]storedefs.Cmd, error)
PrevCmd(upto int, prefix string) (storedefs.Cmd, error)
NextCmd(from int, prefix string) (storedefs.Cmd, error)
}
elvish-0.20.1/pkg/cli/histutil/db_store.go 0000664 0000000 0000000 00000003047 14570151573 0020425 0 ustar 00root root 0000000 0000000 package histutil
import (
"src.elv.sh/pkg/store/storedefs"
)
// NewDBStore returns a Store backed by a database with the view of all
// commands frozen at creation.
func NewDBStore(db DB) (Store, error) {
upper, err := db.NextCmdSeq()
if err != nil {
return nil, err
}
return dbStore{db, upper}, nil
}
type dbStore struct {
db DB
upper int
}
func (s dbStore) AllCmds() ([]storedefs.Cmd, error) {
return s.db.CmdsWithSeq(0, s.upper)
}
func (s dbStore) AddCmd(cmd storedefs.Cmd) (int, error) {
return s.db.AddCmd(cmd.Text)
}
func (s dbStore) Cursor(prefix string) Cursor {
return &dbStoreCursor{
s.db, prefix, s.upper, storedefs.Cmd{Seq: s.upper}, ErrEndOfHistory}
}
type dbStoreCursor struct {
db DB
prefix string
upper int
cmd storedefs.Cmd
err error
}
func (c *dbStoreCursor) Prev() {
if c.cmd.Seq < 0 {
return
}
cmd, err := c.db.PrevCmd(c.cmd.Seq, c.prefix)
c.set(cmd, err, -1)
}
func (c *dbStoreCursor) Next() {
if c.cmd.Seq >= c.upper {
return
}
cmd, err := c.db.NextCmd(c.cmd.Seq+1, c.prefix)
if cmd.Seq < c.upper {
c.set(cmd, err, c.upper)
}
if cmd.Seq >= c.upper {
c.cmd = storedefs.Cmd{Seq: c.upper}
c.err = ErrEndOfHistory
}
}
func (c *dbStoreCursor) set(cmd storedefs.Cmd, err error, endSeq int) {
if err == nil {
c.cmd = cmd
c.err = nil
} else if err.Error() == storedefs.ErrNoMatchingCmd.Error() {
c.cmd = storedefs.Cmd{Seq: endSeq}
c.err = ErrEndOfHistory
} else {
// Don't change c.cmd
c.err = err
}
}
func (c *dbStoreCursor) Get() (storedefs.Cmd, error) {
return c.cmd, c.err
}
elvish-0.20.1/pkg/cli/histutil/db_store_test.go 0000664 0000000 0000000 00000001753 14570151573 0021466 0 ustar 00root root 0000000 0000000 package histutil
import (
"testing"
"src.elv.sh/pkg/store/storedefs"
)
func TestDBStore_Cursor(t *testing.T) {
db := NewFaultyInMemoryDB("+ 1", "- 2", "+ 3")
s, err := NewDBStore(db)
if err != nil {
panic(err)
}
testCursorIteration(t, s.Cursor("+"), []storedefs.Cmd{
{Text: "+ 1", Seq: 0},
{Text: "+ 3", Seq: 2},
})
// Test error conditions.
c := s.Cursor("+")
expect := func(wantCmd storedefs.Cmd, wantErr error) {
t.Helper()
cmd, err := c.Get()
if cmd != wantCmd {
t.Errorf("Get -> %v, want %v", cmd, wantCmd)
}
if err != wantErr {
t.Errorf("Get -> error %v, want %v", err, wantErr)
}
}
db.SetOneOffError(errMock)
c.Prev()
expect(storedefs.Cmd{Seq: 3}, errMock)
c.Prev()
expect(storedefs.Cmd{Text: "+ 3", Seq: 2}, nil)
db.SetOneOffError(errMock)
c.Prev()
expect(storedefs.Cmd{Text: "+ 3", Seq: 2}, errMock)
db.SetOneOffError(errMock)
c.Next()
expect(storedefs.Cmd{Text: "+ 3", Seq: 2}, errMock)
}
// Remaining methods tested with HybridStore.
elvish-0.20.1/pkg/cli/histutil/dedup_cursor.go 0000664 0000000 0000000 00000001677 14570151573 0021331 0 ustar 00root root 0000000 0000000 package histutil
import "src.elv.sh/pkg/store/storedefs"
// NewDedupCursor returns a cursor that skips over all duplicate entries.
func NewDedupCursor(c Cursor) Cursor {
return &dedupCursor{c, 0, nil, make(map[string]bool)}
}
type dedupCursor struct {
c Cursor
current int
stack []storedefs.Cmd
occ map[string]bool
}
func (c *dedupCursor) Prev() {
if c.current < len(c.stack)-1 {
c.current++
return
}
for {
c.c.Prev()
cmd, err := c.c.Get()
if err != nil {
c.current = len(c.stack)
break
}
if !c.occ[cmd.Text] {
c.current = len(c.stack)
c.stack = append(c.stack, cmd)
c.occ[cmd.Text] = true
break
}
}
}
func (c *dedupCursor) Next() {
if c.current >= 0 {
c.current--
}
}
func (c *dedupCursor) Get() (storedefs.Cmd, error) {
switch {
case c.current < 0:
return storedefs.Cmd{}, ErrEndOfHistory
case c.current < len(c.stack):
return c.stack[c.current], nil
default:
return c.c.Get()
}
}
elvish-0.20.1/pkg/cli/histutil/dedup_cursor_test.go 0000664 0000000 0000000 00000001240 14570151573 0022352 0 ustar 00root root 0000000 0000000 package histutil
import (
"testing"
"src.elv.sh/pkg/store/storedefs"
)
func TestDedupCursor(t *testing.T) {
s := NewMemStore("0", "1", "2")
c := NewDedupCursor(s.Cursor(""))
wantCmds := []storedefs.Cmd{
{Text: "0", Seq: 0},
{Text: "1", Seq: 1},
{Text: "2", Seq: 2}}
testCursorIteration(t, c, wantCmds)
// Go back again, this time with a full stack
testCursorIteration(t, c, wantCmds)
c = NewDedupCursor(s.Cursor(""))
// Should be a no-op
c.Next()
testCursorIteration(t, c, wantCmds)
c = NewDedupCursor(s.Cursor(""))
c.Prev()
c.Next()
_, err := c.Get()
if err != ErrEndOfHistory {
t.Errorf("Get -> error %v, want ErrEndOfHistory", err)
}
}
elvish-0.20.1/pkg/cli/histutil/doc.go 0000664 0000000 0000000 00000000132 14570151573 0017361 0 ustar 00root root 0000000 0000000 // Package histutil provides utilities for working with command history.
package histutil
elvish-0.20.1/pkg/cli/histutil/hybrid_store.go 0000664 0000000 0000000 00000003213 14570151573 0021314 0 ustar 00root root 0000000 0000000 package histutil
import "src.elv.sh/pkg/store/storedefs"
// NewHybridStore returns a store that provides a view of all the commands that
// exists in the database, plus a in-memory session history.
func NewHybridStore(db DB) (Store, error) {
if db == nil {
return NewMemStore(), nil
}
dbStore, err := NewDBStore(db)
if err != nil {
return NewMemStore(), err
}
return hybridStore{dbStore, NewMemStore()}, nil
}
type hybridStore struct {
shared, session Store
}
func (s hybridStore) AddCmd(cmd storedefs.Cmd) (int, error) {
seq, err := s.shared.AddCmd(cmd)
s.session.AddCmd(storedefs.Cmd{Text: cmd.Text, Seq: seq})
return seq, err
}
func (s hybridStore) AllCmds() ([]storedefs.Cmd, error) {
shared, err := s.shared.AllCmds()
session, err2 := s.session.AllCmds()
if err == nil {
err = err2
}
if len(shared) == 0 {
return session, err
}
return append(shared, session...), err
}
func (s hybridStore) Cursor(prefix string) Cursor {
return &hybridStoreCursor{
s.shared.Cursor(prefix), s.session.Cursor(prefix), false}
}
type hybridStoreCursor struct {
shared Cursor
session Cursor
useShared bool
}
func (c *hybridStoreCursor) Prev() {
if c.useShared {
c.shared.Prev()
return
}
c.session.Prev()
if _, err := c.session.Get(); err == ErrEndOfHistory {
c.useShared = true
c.shared.Prev()
}
}
func (c *hybridStoreCursor) Next() {
if !c.useShared {
c.session.Next()
return
}
c.shared.Next()
if _, err := c.shared.Get(); err == ErrEndOfHistory {
c.useShared = false
c.session.Next()
}
}
func (c *hybridStoreCursor) Get() (storedefs.Cmd, error) {
if c.useShared {
return c.shared.Get()
}
return c.session.Get()
}
elvish-0.20.1/pkg/cli/histutil/hybrid_store_test.go 0000664 0000000 0000000 00000012343 14570151573 0022357 0 ustar 00root root 0000000 0000000 package histutil
import (
"errors"
"reflect"
"testing"
"src.elv.sh/pkg/store/storedefs"
)
var errMock = errors.New("mock error")
func TestNewHybridStore_ReturnsMemStoreIfDBIsNil(t *testing.T) {
store, err := NewHybridStore(nil)
if _, ok := store.(*memStore); !ok {
t.Errorf("NewHybridStore -> %v, want memStore", store)
}
if err != nil {
t.Errorf("NewHybridStore -> error %v, want nil", err)
}
}
func TestNewHybridStore_ReturnsMemStoreOnDBError(t *testing.T) {
db := NewFaultyInMemoryDB()
db.SetOneOffError(errMock)
store, err := NewHybridStore(db)
if _, ok := store.(*memStore); !ok {
t.Errorf("NewHybridStore -> %v, want memStore", store)
}
if err != errMock {
t.Errorf("NewHybridStore -> error %v, want %v", err, errMock)
}
}
func TestFusuer_AddCmd_AddsBothToDBAndSession(t *testing.T) {
db := NewFaultyInMemoryDB("shared 1")
f := mustNewHybridStore(db)
f.AddCmd(storedefs.Cmd{Text: "session 1"})
wantDBCmds := []storedefs.Cmd{
{Text: "shared 1", Seq: 0}, {Text: "session 1", Seq: 1}}
if dbCmds, _ := db.CmdsWithSeq(-1, -1); !reflect.DeepEqual(dbCmds, wantDBCmds) {
t.Errorf("DB commands = %v, want %v", dbCmds, wantDBCmds)
}
allCmds, err := f.AllCmds()
if err != nil {
panic(err)
}
wantAllCmds := []storedefs.Cmd{
{Text: "shared 1", Seq: 0},
{Text: "session 1", Seq: 1}}
if !reflect.DeepEqual(allCmds, wantAllCmds) {
t.Errorf("AllCmd -> %v, want %v", allCmds, wantAllCmds)
}
}
func TestHybridStore_AddCmd_AddsToSessionEvenIfDBErrors(t *testing.T) {
db := NewFaultyInMemoryDB()
f := mustNewHybridStore(db)
db.SetOneOffError(errMock)
_, err := f.AddCmd(storedefs.Cmd{Text: "haha"})
if err != errMock {
t.Errorf("AddCmd -> error %v, want %v", err, errMock)
}
allCmds, err := f.AllCmds()
if err != nil {
panic(err)
}
wantAllCmds := []storedefs.Cmd{{Text: "haha", Seq: 1}}
if !reflect.DeepEqual(allCmds, wantAllCmds) {
t.Errorf("AllCmd -> %v, want %v", allCmds, wantAllCmds)
}
}
func TestHybridStore_AllCmds_IncludesFrozenSharedAndNewlyAdded(t *testing.T) {
db := NewFaultyInMemoryDB("shared 1")
f := mustNewHybridStore(db)
// Simulate adding commands from both the current session and other sessions.
f.AddCmd(storedefs.Cmd{Text: "session 1"})
db.AddCmd("other session 1")
db.AddCmd("other session 2")
f.AddCmd(storedefs.Cmd{Text: "session 2"})
db.AddCmd("other session 3")
// AllCmds should return all commands from the storage when the HybridStore
// was created, plus session commands. The session commands should have
// sequence numbers consistent with the DB.
allCmds, err := f.AllCmds()
if err != nil {
t.Errorf("AllCmds -> error %v, want nil", err)
}
wantAllCmds := []storedefs.Cmd{
{Text: "shared 1", Seq: 0},
{Text: "session 1", Seq: 1},
{Text: "session 2", Seq: 4}}
if !reflect.DeepEqual(allCmds, wantAllCmds) {
t.Errorf("AllCmds -> %v, want %v", allCmds, wantAllCmds)
}
}
func TestHybridStore_AllCmds_ReturnsSessionIfDBErrors(t *testing.T) {
db := NewFaultyInMemoryDB("shared 1")
f := mustNewHybridStore(db)
f.AddCmd(storedefs.Cmd{Text: "session 1"})
db.SetOneOffError(errMock)
allCmds, err := f.AllCmds()
if err != errMock {
t.Errorf("AllCmds -> error %v, want %v", err, errMock)
}
wantAllCmds := []storedefs.Cmd{{Text: "session 1", Seq: 1}}
if !reflect.DeepEqual(allCmds, wantAllCmds) {
t.Errorf("AllCmd -> %v, want %v", allCmds, wantAllCmds)
}
}
func TestHybridStore_Cursor_OnlySession(t *testing.T) {
db := NewFaultyInMemoryDB()
f := mustNewHybridStore(db)
db.AddCmd("+ other session")
f.AddCmd(storedefs.Cmd{Text: "+ session 1"})
f.AddCmd(storedefs.Cmd{Text: "- no match"})
testCursorIteration(t, f.Cursor("+"), []storedefs.Cmd{{Text: "+ session 1", Seq: 1}})
}
func TestHybridStore_Cursor_OnlyShared(t *testing.T) {
db := NewFaultyInMemoryDB("- no match", "+ shared 1")
f := mustNewHybridStore(db)
db.AddCmd("+ other session")
f.AddCmd(storedefs.Cmd{Text: "- no match"})
testCursorIteration(t, f.Cursor("+"), []storedefs.Cmd{{Text: "+ shared 1", Seq: 1}})
}
func TestHybridStore_Cursor_SharedAndSession(t *testing.T) {
db := NewFaultyInMemoryDB("- no match", "+ shared 1")
f := mustNewHybridStore(db)
db.AddCmd("+ other session")
db.AddCmd("- no match")
f.AddCmd(storedefs.Cmd{Text: "+ session 1"})
f.AddCmd(storedefs.Cmd{Text: "- no match"})
testCursorIteration(t, f.Cursor("+"), []storedefs.Cmd{
{Text: "+ shared 1", Seq: 1},
{Text: "+ session 1", Seq: 4}})
}
func testCursorIteration(t *testing.T, cursor Cursor, wantCmds []storedefs.Cmd) {
expectEndOfHistory := func() {
t.Helper()
if _, err := cursor.Get(); err != ErrEndOfHistory {
t.Errorf("Get -> error %v, want ErrEndOfHistory", err)
}
}
expectCmd := func(i int) {
t.Helper()
wantCmd := wantCmds[i]
cmd, err := cursor.Get()
if cmd != wantCmd {
t.Errorf("Get -> %v, want %v", cmd, wantCmd)
}
if err != nil {
t.Errorf("Get -> error %v, want nil", err)
}
}
expectEndOfHistory()
for i := len(wantCmds) - 1; i >= 0; i-- {
cursor.Prev()
expectCmd(i)
}
cursor.Prev()
expectEndOfHistory()
cursor.Prev()
expectEndOfHistory()
for i := range wantCmds {
cursor.Next()
expectCmd(i)
}
cursor.Next()
expectEndOfHistory()
cursor.Next()
expectEndOfHistory()
}
func mustNewHybridStore(db DB) Store {
f, err := NewHybridStore(db)
if err != nil {
panic(err)
}
return f
}
elvish-0.20.1/pkg/cli/histutil/mem_store.go 0000664 0000000 0000000 00000002540 14570151573 0020613 0 ustar 00root root 0000000 0000000 package histutil
import (
"strings"
"src.elv.sh/pkg/store/storedefs"
)
// NewMemStore returns a Store that stores command history in memory.
func NewMemStore(texts ...string) Store {
cmds := make([]storedefs.Cmd, len(texts))
for i, text := range texts {
cmds[i] = storedefs.Cmd{Text: text, Seq: i}
}
return &memStore{cmds}
}
type memStore struct{ cmds []storedefs.Cmd }
func (s *memStore) AllCmds() ([]storedefs.Cmd, error) {
return s.cmds, nil
}
func (s *memStore) AddCmd(cmd storedefs.Cmd) (int, error) {
if cmd.Seq < 0 {
cmd.Seq = len(s.cmds) + 1
}
s.cmds = append(s.cmds, cmd)
return cmd.Seq, nil
}
func (s *memStore) Cursor(prefix string) Cursor {
return &memStoreCursor{s.cmds, prefix, len(s.cmds)}
}
type memStoreCursor struct {
cmds []storedefs.Cmd
prefix string
index int
}
func (c *memStoreCursor) Prev() {
if c.index < 0 {
return
}
for c.index--; c.index >= 0; c.index-- {
if strings.HasPrefix(c.cmds[c.index].Text, c.prefix) {
return
}
}
}
func (c *memStoreCursor) Next() {
if c.index >= len(c.cmds) {
return
}
for c.index++; c.index < len(c.cmds); c.index++ {
if strings.HasPrefix(c.cmds[c.index].Text, c.prefix) {
return
}
}
}
func (c *memStoreCursor) Get() (storedefs.Cmd, error) {
if c.index < 0 || c.index >= len(c.cmds) {
return storedefs.Cmd{}, ErrEndOfHistory
}
return c.cmds[c.index], nil
}
elvish-0.20.1/pkg/cli/histutil/mem_store_test.go 0000664 0000000 0000000 00000000500 14570151573 0021644 0 ustar 00root root 0000000 0000000 package histutil
import (
"testing"
"src.elv.sh/pkg/store/storedefs"
)
func TestMemStore_Cursor(t *testing.T) {
s := NewMemStore("+ 0", "- 1", "+ 2")
testCursorIteration(t, s.Cursor("+"), []storedefs.Cmd{
{Text: "+ 0", Seq: 0},
{Text: "+ 2", Seq: 2},
})
}
// Remaining methods tested along with HybridStore
elvish-0.20.1/pkg/cli/histutil/store.go 0000664 0000000 0000000 00000002276 14570151573 0017763 0 ustar 00root root 0000000 0000000 package histutil
import (
"errors"
"src.elv.sh/pkg/store/storedefs"
)
// Store is an abstract interface for history store.
type Store interface {
// AddCmd adds a new command history entry and returns its sequence number.
// Depending on the implementation, the Store might respect cmd.Seq and
// return it as is, or allocate another sequence number.
AddCmd(cmd storedefs.Cmd) (int, error)
// AllCmds returns all commands kept in the store.
AllCmds() ([]storedefs.Cmd, error)
// Cursor returns a cursor that iterating through commands with the given
// prefix. The cursor is initially placed just after the last command in the
// store.
Cursor(prefix string) Cursor
}
// Cursor is used to navigate a Store.
type Cursor interface {
// Prev moves the cursor to the previous command.
Prev()
// Next moves the cursor to the next command.
Next()
// Get returns the command the cursor is currently at, or any error if the
// cursor is in an invalid state. If the cursor is "over the edge", the
// error is ErrEndOfHistory.
Get() (storedefs.Cmd, error)
}
// ErrEndOfHistory is returned by Cursor.Get if the cursor is currently over the
// edge.
var ErrEndOfHistory = errors.New("end of history")
elvish-0.20.1/pkg/cli/histutil/test_db.go 0000664 0000000 0000000 00000004024 14570151573 0020244 0 ustar 00root root 0000000 0000000 package histutil
import (
"strings"
"src.elv.sh/pkg/store/storedefs"
)
// FaultyInMemoryDB is an in-memory DB implementation that can be injected
// one-off errors. It is useful in tests.
type FaultyInMemoryDB interface {
DB
// SetOneOffError causes the next operation on the database to return the
// given error.
SetOneOffError(err error)
}
// NewFaultyInMemoryDB creates a new FaultyInMemoryDB with the given commands.
func NewFaultyInMemoryDB(cmds ...string) FaultyInMemoryDB {
return &testDB{cmds: cmds}
}
// Implementation of FaultyInMemoryDB.
type testDB struct {
cmds []string
oneOffError error
}
func (s *testDB) SetOneOffError(err error) {
s.oneOffError = err
}
func (s *testDB) error() error {
err := s.oneOffError
s.oneOffError = nil
return err
}
func (s *testDB) NextCmdSeq() (int, error) {
return len(s.cmds), s.error()
}
func (s *testDB) AddCmd(cmd string) (int, error) {
if s.oneOffError != nil {
return -1, s.error()
}
s.cmds = append(s.cmds, cmd)
return len(s.cmds) - 1, nil
}
func (s *testDB) CmdsWithSeq(from, upto int) ([]storedefs.Cmd, error) {
if err := s.error(); err != nil {
return nil, err
}
if from < 0 {
from = 0
}
if upto < 0 || upto > len(s.cmds) {
upto = len(s.cmds)
}
var cmds []storedefs.Cmd
for i := from; i < upto; i++ {
cmds = append(cmds, storedefs.Cmd{Text: s.cmds[i], Seq: i})
}
return cmds, nil
}
func (s *testDB) PrevCmd(upto int, prefix string) (storedefs.Cmd, error) {
if s.oneOffError != nil {
return storedefs.Cmd{}, s.error()
}
for i := upto - 1; i >= 0; i-- {
if strings.HasPrefix(s.cmds[i], prefix) {
return storedefs.Cmd{Text: s.cmds[i], Seq: i}, nil
}
}
return storedefs.Cmd{}, storedefs.ErrNoMatchingCmd
}
func (s *testDB) NextCmd(from int, prefix string) (storedefs.Cmd, error) {
if s.oneOffError != nil {
return storedefs.Cmd{}, s.error()
}
for i := from; i < len(s.cmds); i++ {
if strings.HasPrefix(s.cmds[i], prefix) {
return storedefs.Cmd{Text: s.cmds[i], Seq: i}, nil
}
}
return storedefs.Cmd{}, storedefs.ErrNoMatchingCmd
}
elvish-0.20.1/pkg/cli/loop.go 0000664 0000000 0000000 00000007060 14570151573 0015727 0 ustar 00root root 0000000 0000000 package cli
import "sync"
// Buffer size of the input channel. The value is chosen for no particular
// reason.
const inputChSize = 128
// A generic main loop manager.
type loop struct {
inputCh chan event
handleCb handleCb
redrawCb redrawCb
redrawCh chan struct{}
redrawFull bool
redrawMutex *sync.Mutex
returnCh chan loopReturn
}
type loopReturn struct {
buffer string
err error
}
// A placeholder type for events.
type event any
// Callback for redrawing the editor UI to the terminal.
type redrawCb func(flag redrawFlag)
func dummyRedrawCb(redrawFlag) {}
// Flag to redrawCb.
type redrawFlag uint
// Bit flags for redrawFlag.
const (
// fullRedraw signals a "full redraw". This is set on the first RedrawCb
// call or when Redraw has been called with full = true.
fullRedraw redrawFlag = 1 << iota
// finalRedraw signals that this is the final redraw in the event loop.
finalRedraw
)
// Callback for handling a terminal event.
type handleCb func(event)
func dummyHandleCb(event) {}
// newLoop creates a new Loop instance.
func newLoop() *loop {
return &loop{
inputCh: make(chan event, inputChSize),
handleCb: dummyHandleCb,
redrawCb: dummyRedrawCb,
redrawCh: make(chan struct{}, 1),
redrawFull: false,
redrawMutex: new(sync.Mutex),
returnCh: make(chan loopReturn, 1),
}
}
// HandleCb sets the handle callback. It must be called before any Read call.
func (lp *loop) HandleCb(cb handleCb) {
lp.handleCb = cb
}
// RedrawCb sets the redraw callback. It must be called before any Read call.
func (lp *loop) RedrawCb(cb redrawCb) {
lp.redrawCb = cb
}
// Redraw requests a redraw. If full is true, a full redraw is requested. It
// never blocks.
func (lp *loop) Redraw(full bool) {
lp.redrawMutex.Lock()
defer lp.redrawMutex.Unlock()
if full {
lp.redrawFull = true
}
select {
case lp.redrawCh <- struct{}{}:
default:
}
}
// Input provides an input event. It may block if the internal event buffer is
// full.
func (lp *loop) Input(ev event) {
lp.inputCh <- ev
}
// Return requests the main loop to return. It never blocks. If Return has been
// called before during the current loop iteration, it has no effect.
func (lp *loop) Return(buffer string, err error) {
select {
case lp.returnCh <- loopReturn{buffer, err}:
default:
}
}
// HasReturned returns whether Return has been called during the current loop
// iteration.
func (lp *loop) HasReturned() bool {
return len(lp.returnCh) == 1
}
// Run runs the event loop, until the Return method is called. It is generic
// and delegates all concrete work to callbacks. It is fully serial: it does
// not spawn any goroutines and never calls two callbacks in parallel, so the
// callbacks may manipulate shared states without synchronization.
func (lp *loop) Run() (buffer string, err error) {
for {
var flag redrawFlag
if lp.extractRedrawFull() {
flag |= fullRedraw
}
lp.redrawCb(flag)
select {
case event := <-lp.inputCh:
// Consume all events in the channel to minimize redraws.
consumeAllEvents:
for {
lp.handleCb(event)
select {
case ret := <-lp.returnCh:
lp.redrawCb(finalRedraw)
return ret.buffer, ret.err
default:
}
select {
case event = <-lp.inputCh:
// Continue the loop of consuming all events.
default:
break consumeAllEvents
}
}
case ret := <-lp.returnCh:
lp.redrawCb(finalRedraw)
return ret.buffer, ret.err
case <-lp.redrawCh:
}
}
}
func (lp *loop) extractRedrawFull() bool {
lp.redrawMutex.Lock()
defer lp.redrawMutex.Unlock()
full := lp.redrawFull
lp.redrawFull = false
return full
}
elvish-0.20.1/pkg/cli/loop_test.go 0000664 0000000 0000000 00000007743 14570151573 0016776 0 ustar 00root root 0000000 0000000 package cli
import (
"fmt"
"io"
"reflect"
"testing"
)
func TestRead_PassesInputEventsToHandler(t *testing.T) {
var handlerGotEvents []event
lp := newLoop()
lp.HandleCb(func(e event) {
handlerGotEvents = append(handlerGotEvents, e)
if e == "^D" {
lp.Return("", nil)
}
})
inputPassedEvents := []event{"foo", "bar", "lorem", "ipsum", "^D"}
supplyInputs(lp, inputPassedEvents...)
_, _ = lp.Run()
if !reflect.DeepEqual(handlerGotEvents, inputPassedEvents) {
t.Errorf("Handler got events %v, expect same as events passed to input (%v)",
handlerGotEvents, inputPassedEvents)
}
}
func TestLoop_RunReturnsAfterReturnCalled(t *testing.T) {
lp := newLoop()
lp.HandleCb(func(event) { lp.Return("buffer", io.EOF) })
supplyInputs(lp, "x")
buf, err := lp.Run()
if buf != "buffer" || err != io.EOF {
fmt.Printf("Run -> (%v, %v), want (%v, %v)", buf, err, "buffer", io.EOF)
}
}
func TestRead_CallsDrawWhenRedrawRequestedBeforeRead(t *testing.T) {
testReadCallsDrawWhenRedrawRequestedBeforeRead(t, true, fullRedraw)
testReadCallsDrawWhenRedrawRequestedBeforeRead(t, false, 0)
}
func testReadCallsDrawWhenRedrawRequestedBeforeRead(t *testing.T, full bool, wantRedrawFlag redrawFlag) {
t.Helper()
var gotRedrawFlag redrawFlag
drawSeq := 0
doneCh := make(chan struct{})
drawer := func(full redrawFlag) {
if drawSeq == 0 {
gotRedrawFlag = full
close(doneCh)
}
drawSeq++
}
lp := newLoop()
lp.HandleCb(quitOn(lp, "^D", "", nil))
go func() {
<-doneCh
lp.Input("^D")
}()
lp.RedrawCb(drawer)
lp.Redraw(full)
_, _ = lp.Run()
if gotRedrawFlag != wantRedrawFlag {
t.Errorf("Drawer got flag %v, want %v", gotRedrawFlag, wantRedrawFlag)
}
}
func TestRead_callsDrawWhenRedrawRequestedAfterFirstDraw(t *testing.T) {
testReadCallsDrawWhenRedrawRequestedAfterFirstDraw(t, true, fullRedraw)
testReadCallsDrawWhenRedrawRequestedAfterFirstDraw(t, false, 0)
}
func testReadCallsDrawWhenRedrawRequestedAfterFirstDraw(t *testing.T, full bool, wantRedrawFlag redrawFlag) {
t.Helper()
var gotRedrawFlag redrawFlag
drawSeq := 0
firstDrawCalledCh := make(chan struct{})
doneCh := make(chan struct{})
drawer := func(flag redrawFlag) {
if drawSeq == 0 {
close(firstDrawCalledCh)
} else if drawSeq == 1 {
gotRedrawFlag = flag
close(doneCh)
}
drawSeq++
}
lp := newLoop()
lp.HandleCb(quitOn(lp, "^D", "", nil))
go func() {
<-doneCh
lp.Input("^D")
}()
lp.RedrawCb(drawer)
go func() {
<-firstDrawCalledCh
lp.Redraw(full)
}()
_, _ = lp.Run()
if gotRedrawFlag != wantRedrawFlag {
t.Errorf("Drawer got flag %v, want %v", gotRedrawFlag, wantRedrawFlag)
}
}
// Helpers.
func supplyInputs(lp *loop, events ...event) {
for _, event := range events {
lp.Input(event)
}
}
// Returns a HandleCb that quits on a trigger event.
func quitOn(lp *loop, retTrigger event, ret string, err error) handleCb {
return func(e event) {
if e == retTrigger {
lp.Return(ret, err)
}
}
}
func TestLoop_FullLifecycle(t *testing.T) {
// A test for the entire lifecycle of a loop.
var initialBuffer, finalBuffer string
buffer := ""
firstDrawerCall := true
drawer := func(flag redrawFlag) {
// Because the consumption of events is batched, calls to the drawer is
// nondeterministic except for the first and final calls.
switch {
case firstDrawerCall:
initialBuffer = buffer
firstDrawerCall = false
case flag&finalRedraw != 0:
finalBuffer = buffer
}
}
lp := newLoop()
lp.HandleCb(func(e event) {
if e == '\n' {
lp.Return(buffer, nil)
return
}
buffer += string(e.(rune))
})
go func() {
for _, event := range "echo\n" {
lp.Input(event)
}
}()
lp.RedrawCb(drawer)
returnedBuffer, err := lp.Run()
if initialBuffer != "" {
t.Errorf("got initial buffer %q, want %q", initialBuffer, "")
}
if finalBuffer != "echo" {
t.Errorf("got final buffer %q, want %q", finalBuffer, "echo")
}
if returnedBuffer != "echo" {
t.Errorf("got returned buffer %q, want %q", returnedBuffer, "echo")
}
if err != nil {
t.Errorf("got error %v, want nil", err)
}
}
elvish-0.20.1/pkg/cli/lscolors/ 0000775 0000000 0000000 00000000000 14570151573 0016264 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/cli/lscolors/feature.go 0000664 0000000 0000000 00000005036 14570151573 0020252 0 ustar 00root root 0000000 0000000 package lscolors
import (
"os"
"src.elv.sh/pkg/fsutil"
)
type feature int
const (
featureInvalid feature = iota
featureOrphanedSymlink
featureSymlink
featureMultiHardLink
featureNamedPipe
featureSocket
featureDoor
featureBlockDevice
featureCharDevice
featureWorldWritableStickyDirectory
featureWorldWritableDirectory
featureStickyDirectory
featureDirectory
featureCapability
featureSetuid
featureSetgid
featureExecutable
featureRegular
)
// Some platforms, such as Windows, have simulated Unix style permission masks.
// On Windows the only two permission masks are 0o666 (RW) and 0o444 (RO).
const worldWritable = 0o002
// Can be mutated in tests.
var isDoorFunc = isDoor
func determineFeature(fname string, mh bool) (feature, error) {
stat, err := os.Lstat(fname)
if err != nil {
return featureInvalid, err
}
m := stat.Mode()
// Symlink and OrphanedSymlink has highest precedence
if is(m, os.ModeSymlink) {
_, err := os.Stat(fname)
if err != nil {
return featureOrphanedSymlink, nil
}
return featureSymlink, nil
}
// featureMultiHardLink
if mh && isMultiHardlink(stat) {
return featureMultiHardLink, nil
}
// type bits features
switch {
case is(m, os.ModeNamedPipe):
return featureNamedPipe, nil
case is(m, os.ModeSocket): // Never on Windows
return featureSocket, nil
case isDoorFunc(stat):
return featureDoor, nil
case is(m, os.ModeCharDevice):
return featureCharDevice, nil
case is(m, os.ModeDevice):
// There is no dedicated os.Mode* flag for block device. On all
// supported Unix platforms, when os.ModeDevice is set but
// os.ModeCharDevice is not, the file is a block device (i.e.
// syscall.S_IFBLK is set). On Windows, this branch is unreachable.
//
// On Plan9, this in inaccurate.
return featureBlockDevice, nil
case is(m, os.ModeDir):
// Perm bits features for directory
perm := m.Perm()
switch {
case is(m, os.ModeSticky) && is(perm, worldWritable):
return featureWorldWritableStickyDirectory, nil
case is(perm, worldWritable):
return featureWorldWritableDirectory, nil
case is(m, os.ModeSticky):
return featureStickyDirectory, nil
default:
return featureDirectory, nil
}
}
// TODO(xiaq): Support featureCapacity
// Perm bits features for regular files
switch {
case is(m, os.ModeSetuid):
return featureSetuid, nil
case is(m, os.ModeSetgid):
return featureSetgid, nil
case fsutil.IsExecutable(stat):
return featureExecutable, nil
}
// Check extension
return featureRegular, nil
}
func is(m, p os.FileMode) bool {
return m&p == p
}
elvish-0.20.1/pkg/cli/lscolors/feature_nonunix_test.go 0000664 0000000 0000000 00000000344 14570151573 0023064 0 ustar 00root root 0000000 0000000 //go:build windows || plan9 || js
package lscolors
import (
"errors"
)
var errNotSupportedOnNonUnix = errors.New("not supported on non-Unix OS")
func createNamedPipe(fname string) error {
return errNotSupportedOnNonUnix
}
elvish-0.20.1/pkg/cli/lscolors/feature_test.go 0000664 0000000 0000000 00000010375 14570151573 0021313 0 ustar 00root root 0000000 0000000 package lscolors
import (
"fmt"
"net"
"os"
"runtime"
"testing"
"src.elv.sh/pkg/testutil"
)
type opt struct {
setupErr error
mh bool
wantErr bool
}
func TestDetermineFeature(t *testing.T) {
testutil.InTempDir(t)
testutil.Umask(t, 0)
test := func(name, fname string, wantFeature feature, o opt) {
t.Helper()
t.Run(name, func(t *testing.T) {
t.Helper()
if o.setupErr != nil {
t.Skip("skipped due to setup error:", o.setupErr)
}
feature, err := determineFeature(fname, o.mh)
wantErr := "nil"
if o.wantErr {
wantErr = "non-nil"
}
if (err != nil) != o.wantErr {
t.Errorf("determineFeature(%q, %v) returns error %v, want %v",
fname, o.mh, err, wantErr)
}
if feature != wantFeature {
t.Errorf("determineFeature(%q, %v) returns feature %v, want %v",
fname, o.mh, feature, wantFeature)
}
})
}
err := create("a")
test("regular file", "a", featureRegular, opt{setupErr: err})
test("regular file mh=true", "a", featureRegular, opt{setupErr: err, mh: true})
err = os.Symlink("a", "l")
test("symlink", "l", featureSymlink, opt{setupErr: err})
err = os.Symlink("aaaa", "lbad")
test("broken symlink", "lbad", featureOrphanedSymlink, opt{setupErr: err})
if runtime.GOOS != "windows" {
err := os.Link("a", "a2")
test("multi hard link", "a", featureMultiHardLink, opt{mh: true, setupErr: err})
test("multi hard link with mh=false", "a", featureRegular, opt{setupErr: err})
}
err = createNamedPipe("fifo")
test("named pipe", "fifo", featureNamedPipe, opt{setupErr: err})
if runtime.GOOS != "windows" {
l, err := net.Listen("unix", "sock")
if err == nil {
defer l.Close()
}
test("socket", "sock", featureSocket, opt{setupErr: err})
}
testutil.Set(t, &isDoorFunc,
func(info os.FileInfo) bool { return info.Name() == "door" })
err = create("door")
test("door (fake)", "door", featureDoor, opt{setupErr: err})
chr, err := findDevice(os.ModeDevice | os.ModeCharDevice)
test("char device", chr, featureCharDevice, opt{setupErr: err})
blk, err := findDevice(os.ModeDevice)
test("block device", blk, featureBlockDevice, opt{setupErr: err})
err = mkdirMode("d", 0700)
test("normal dir", "d", featureDirectory, opt{setupErr: err})
// Regression test for b.elv.sh/1710.
test("directory with mh=true", "d", featureDirectory, opt{setupErr: err, mh: true})
err = mkdirMode("d-wws", 0777|os.ModeSticky)
test("world-writable sticky dir", "d-wws", featureWorldWritableStickyDirectory, opt{setupErr: err})
err = mkdirMode("d-ww", 0777)
test("world-writable dir", "d-ww", featureWorldWritableDirectory, opt{setupErr: err})
err = mkdirMode("d-s", 0700|os.ModeSticky)
test("sticky dir", "d-s", featureStickyDirectory, opt{setupErr: err})
err = createMode("xu", 0100)
test("executable by user", "xu", featureExecutable, opt{setupErr: err})
err = createMode("xg", 0010)
test("executable by group", "xg", featureExecutable, opt{setupErr: err})
err = createMode("xo", 0001)
test("executable by other", "xo", featureExecutable, opt{setupErr: err})
err = createMode("su", 0600|os.ModeSetuid)
test("setuid", "su", featureSetuid, opt{setupErr: err})
err = createMode("sg", 0600|os.ModeSetgid)
test("setgid", "sg", featureSetgid, opt{setupErr: err})
test("nonexistent file", "nonexistent", featureInvalid, opt{wantErr: true})
}
func create(fname string) error {
f, err := os.Create(fname)
if err == nil {
f.Close()
}
return err
}
func createMode(fname string, mode os.FileMode) error {
f, err := os.OpenFile(fname, os.O_CREATE, mode)
if err != nil {
return err
}
f.Close()
return checkMode(fname, mode)
}
func findDevice(typ os.FileMode) (string, error) {
entries, err := os.ReadDir("/dev")
if err != nil {
return "", err
}
for _, entry := range entries {
if entry.Type() == typ {
return "/dev/" + entry.Name(), nil
}
}
return "", fmt.Errorf("can't find %v device under /dev", typ)
}
func mkdirMode(fname string, mode os.FileMode) error {
if err := os.Mkdir(fname, mode); err != nil {
return err
}
return checkMode(fname, mode|os.ModeDir)
}
func checkMode(fname string, wantMode os.FileMode) error {
info, err := os.Lstat(fname)
if err != nil {
return err
}
if mode := info.Mode(); mode != wantMode {
return fmt.Errorf("created file has mode %v, want %v", mode, wantMode)
}
return nil
}
elvish-0.20.1/pkg/cli/lscolors/feature_unix_test.go 0000664 0000000 0000000 00000000226 14570151573 0022350 0 ustar 00root root 0000000 0000000 //go:build unix
package lscolors
import (
"golang.org/x/sys/unix"
)
func createNamedPipe(fname string) error {
return unix.Mkfifo(fname, 0600)
}
elvish-0.20.1/pkg/cli/lscolors/lscolors.go 0000664 0000000 0000000 00000011013 14570151573 0020447 0 ustar 00root root 0000000 0000000 // Package lscolors provides styling of filenames based on file features.
//
// This is a reverse-engineered implementation of the parsing and
// interpretation of the LS_COLORS environmental variable used by GNU
// coreutils.
package lscolors
import (
"os"
"path"
"strings"
"sync"
"src.elv.sh/pkg/env"
"src.elv.sh/pkg/testutil"
)
// Colorist styles filenames based on the features of the file.
type Colorist interface {
// GetStyle returns the style for the named file.
GetStyle(fname string) string
}
type colorist struct {
styleForFeature map[feature]string
styleForExt map[string]string
}
const defaultLsColorString = `rs=:di=01;34:ln=01;36:mh=:pi=40;33:so=01;35:do=01;35:bd=40;33;01:cd=40;33;01:or=40;31;01:su=37;41:sg=30;43:ca=30;41:tw=30;42:ow=34;42:st=37;44:ex=01;32:*.tar=01;31:*.tgz=01;31:*.arc=01;31:*.arj=01;31:*.taz=01;31:*.lha=01;31:*.lz4=01;31:*.lzh=01;31:*.lzma=01;31:*.tlz=01;31:*.txz=01;31:*.tzo=01;31:*.t7z=01;31:*.zip=01;31:*.z=01;31:*.Z=01;31:*.dz=01;31:*.gz=01;31:*.lrz=01;31:*.lz=01;31:*.lzo=01;31:*.xz=01;31:*.bz2=01;31:*.bz=01;31:*.tbz=01;31:*.tbz2=01;31:*.tz=01;31:*.deb=01;31:*.rpm=01;31:*.jar=01;31:*.war=01;31:*.ear=01;31:*.sar=01;31:*.rar=01;31:*.alz=01;31:*.ace=01;31:*.zoo=01;31:*.cpio=01;31:*.7z=01;31:*.rz=01;31:*.cab=01;31:*.jpg=01;35:*.jpeg=01;35:*.gif=01;35:*.bmp=01;35:*.pbm=01;35:*.pgm=01;35:*.ppm=01;35:*.tga=01;35:*.xbm=01;35:*.xpm=01;35:*.tif=01;35:*.tiff=01;35:*.png=01;35:*.svg=01;35:*.svgz=01;35:*.mng=01;35:*.pcx=01;35:*.mov=01;35:*.mpg=01;35:*.mpeg=01;35:*.m2v=01;35:*.mkv=01;35:*.webm=01;35:*.ogm=01;35:*.mp4=01;35:*.m4v=01;35:*.mp4v=01;35:*.vob=01;35:*.qt=01;35:*.nuv=01;35:*.wmv=01;35:*.asf=01;35:*.rm=01;35:*.rmvb=01;35:*.flc=01;35:*.avi=01;35:*.fli=01;35:*.flv=01;35:*.gl=01;35:*.dl=01;35:*.xcf=01;35:*.xwd=01;35:*.yuv=01;35:*.cgm=01;35:*.emf=01;35:*.axv=01;35:*.anx=01;35:*.ogv=01;35:*.ogx=01;35:*.aac=36:*.au=36:*.flac=36:*.mid=36:*.midi=36:*.mka=36:*.mp3=36:*.mpc=36:*.ogg=36:*.ra=36:*.wav=36:*.axa=36:*.oga=36:*.spx=36:*.xspf=36:`
var (
lastColorist *colorist
lastColoristMutex sync.Mutex
lastLsColors string
)
func init() {
lastColorist = parseLsColor(defaultLsColorString)
}
func GetColorist() Colorist {
lastColoristMutex.Lock()
defer lastColoristMutex.Unlock()
s := getLsColors()
if lastLsColors != s {
lastLsColors = s
lastColorist = parseLsColor(s)
}
return lastColorist
}
func getLsColors() string {
lsColorString := os.Getenv(env.LS_COLORS)
if len(lsColorString) == 0 {
return defaultLsColorString
}
return lsColorString
}
var featureForName = map[string]feature{
"rs": featureRegular,
"di": featureDirectory,
"ln": featureSymlink,
"mh": featureMultiHardLink,
"pi": featureNamedPipe,
"so": featureSocket,
"do": featureDoor,
"bd": featureBlockDevice,
"cd": featureCharDevice,
"or": featureOrphanedSymlink,
"su": featureSetuid,
"sg": featureSetgid,
"ca": featureCapability,
"tw": featureWorldWritableStickyDirectory,
"ow": featureWorldWritableDirectory,
"st": featureStickyDirectory,
"ex": featureExecutable,
}
// parseLsColor parses a string in the LS_COLORS format into lsColor. Erroneous
// fields are silently ignored.
func parseLsColor(s string) *colorist {
lc := &colorist{make(map[feature]string), make(map[string]string)}
for _, spec := range strings.Split(s, ":") {
words := strings.Split(spec, "=")
if len(words) != 2 {
continue
}
key, value := words[0], words[1]
filterValues := []string{}
for _, splitValue := range strings.Split(value, ";") {
if strings.Count(splitValue, "0") == len(splitValue) {
continue
}
filterValues = append(filterValues, splitValue)
}
if len(filterValues) == 0 {
continue
}
value = strings.Join(filterValues, ";")
if strings.HasPrefix(key, "*.") {
lc.styleForExt[key[1:]] = value
} else {
feature, ok := featureForName[key]
if !ok {
continue
}
lc.styleForFeature[feature] = value
}
}
return lc
}
func (lc *colorist) GetStyle(fname string) string {
mh := strings.Trim(lc.styleForFeature[featureMultiHardLink], "0") != ""
// TODO Handle error from determineFeature
feature, _ := determineFeature(fname, mh)
if feature == featureRegular {
if ext := path.Ext(fname); ext != "" {
if style, ok := lc.styleForExt[ext]; ok {
return style
}
}
}
return lc.styleForFeature[feature]
}
// SetTestLsColors sets LS_COLORS to a value where directories are blue and
// .png files are red for the duration of a test.
func SetTestLsColors(c testutil.Cleanuper) {
// ow (world-writable directory) needed for Windows.
testutil.Setenv(c, "LS_COLORS", "di=34:ow=34:*.png=31")
}
elvish-0.20.1/pkg/cli/lscolors/lscolors_test.go 0000664 0000000 0000000 00000002261 14570151573 0021513 0 ustar 00root root 0000000 0000000 package lscolors
import (
"os"
"testing"
"src.elv.sh/pkg/testutil"
)
func TestLsColors(t *testing.T) {
SetTestLsColors(t)
testutil.InTempDir(t)
os.Mkdir("dir", 0755)
create("a.png")
colorist := GetColorist()
// Feature-based coloring.
wantDirStyle := "34"
if style := colorist.GetStyle("dir"); style != wantDirStyle {
t.Errorf("Got dir style %q, want %q", style, wantDirStyle)
}
// Extension-based coloring.
wantPngStyle := "31"
if style := colorist.GetStyle("a.png"); style != wantPngStyle {
t.Errorf("Got dir style %q, want %q", style, wantPngStyle)
}
}
func TestLsColors_SkipsInvalidFields(t *testing.T) {
testutil.Setenv(t, "LS_COLORS", "invalid=34:*.png=31")
testutil.InTempDir(t)
create("a.png")
wantPngStyle := "31"
if style := GetColorist().GetStyle("a.png"); style != wantPngStyle {
t.Errorf("Got dir style %q, want %q", style, wantPngStyle)
}
}
func TestLsColors_Default(t *testing.T) {
testutil.Setenv(t, "LS_COLORS", "")
testutil.InTempDir(t)
create("a.png")
// See defaultLsColorString
wantPngStyle := "01;35"
if style := GetColorist().GetStyle("a.png"); style != wantPngStyle {
t.Errorf("Got dir style %q, want %q", style, wantPngStyle)
}
}
elvish-0.20.1/pkg/cli/lscolors/stat_notsolaris.go 0000664 0000000 0000000 00000000222 14570151573 0022037 0 ustar 00root root 0000000 0000000 //go:build !solaris
package lscolors
import "os"
func isDoor(info os.FileInfo) bool {
// Doors are only supported on Solaris.
return false
}
elvish-0.20.1/pkg/cli/lscolors/stat_solaris.go 0000664 0000000 0000000 00000000316 14570151573 0021322 0 ustar 00root root 0000000 0000000 package lscolors
import (
"os"
"syscall"
)
// Taken from Illumos header file.
const sIFDOOR = 0xD000
func isDoor(info os.FileInfo) bool {
return info.Sys().(*syscall.Stat_t).Mode&sIFDOOR == sIFDOOR
}
elvish-0.20.1/pkg/cli/lscolors/stat_unix.go 0000664 0000000 0000000 00000001043 14570151573 0020627 0 ustar 00root root 0000000 0000000 //go:build unix
package lscolors
import (
"os"
"syscall"
)
func isMultiHardlink(info os.FileInfo) bool {
// The nlink field from stat considers all the "." and ".." references to
// directories to be hard links, making all directories technically
// multi-hardlink (one link from parent, one "." from itself, and one ".."
// for every subdirectories). However, for the purpose of filename
// highlighting, only regular files should ever be considered
// multi-hardlink.
return !info.IsDir() && info.Sys().(*syscall.Stat_t).Nlink > 1
}
elvish-0.20.1/pkg/cli/lscolors/stat_windows.go 0000664 0000000 0000000 00000000343 14570151573 0021340 0 ustar 00root root 0000000 0000000 package lscolors
import "os"
func isMultiHardlink(info os.FileInfo) bool {
// Windows supports hardlinks, but it is not exposed directly. We omit the
// implementation for now.
// TODO: Maybe implement it?
return false
}
elvish-0.20.1/pkg/cli/modes/ 0000775 0000000 0000000 00000000000 14570151573 0015533 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/cli/modes/completion.go 0000664 0000000 0000000 00000005074 14570151573 0020241 0 ustar 00root root 0000000 0000000 package modes
import (
"errors"
"strings"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/diag"
"src.elv.sh/pkg/ui"
)
// Completion is a mode specialized for viewing and inserting completion
// candidates. It is based on the ComboBox widget.
type Completion interface {
tk.ComboBox
}
// CompletionSpec specifies the configuration for the completion mode.
type CompletionSpec struct {
Bindings tk.Bindings
Name string
Replace diag.Ranging
Items []CompletionItem
Filter FilterSpec
}
// CompletionItem represents a completion item, also known as a candidate.
type CompletionItem struct {
// Used in the UI and for filtering.
ToShow ui.Text
// Used when inserting a candidate.
ToInsert string
}
type completion struct {
tk.ComboBox
attached tk.CodeArea
}
var errNoCandidates = errors.New("no candidates")
// NewCompletion starts the completion UI.
func NewCompletion(app cli.App, cfg CompletionSpec) (Completion, error) {
codeArea, err := FocusedCodeArea(app)
if err != nil {
return nil, err
}
if len(cfg.Items) == 0 {
return nil, errNoCandidates
}
w := tk.NewComboBox(tk.ComboBoxSpec{
CodeArea: tk.CodeAreaSpec{
Prompt: modePrompt(" COMPLETING "+cfg.Name+" ", true),
Highlighter: cfg.Filter.Highlighter,
},
ListBox: tk.ListBoxSpec{
Horizontal: true,
Bindings: cfg.Bindings,
OnSelect: func(it tk.Items, i int) {
text := it.(completionItems)[i].ToInsert
codeArea.MutateState(func(s *tk.CodeAreaState) {
s.Pending = tk.PendingCode{
From: cfg.Replace.From, To: cfg.Replace.To, Content: text}
})
},
OnAccept: func(it tk.Items, i int) {
codeArea.MutateState((*tk.CodeAreaState).ApplyPending)
app.PopAddon()
},
ExtendStyle: true,
},
OnFilter: func(w tk.ComboBox, p string) {
w.ListBox().Reset(filterCompletionItems(cfg.Items, cfg.Filter.makePredicate(p)), 0)
},
})
return completion{w, codeArea}, nil
}
func (w completion) Dismiss() {
w.attached.MutateState(func(s *tk.CodeAreaState) { s.Pending = tk.PendingCode{} })
}
type completionItems []CompletionItem
func filterCompletionItems(all []CompletionItem, p func(string) bool) completionItems {
var filtered []CompletionItem
for _, candidate := range all {
if p(unstyle(candidate.ToShow)) {
filtered = append(filtered, candidate)
}
}
return filtered
}
func (it completionItems) Show(i int) ui.Text { return it[i].ToShow }
func (it completionItems) Len() int { return len(it) }
func unstyle(t ui.Text) string {
var sb strings.Builder
for _, seg := range t {
sb.WriteString(seg.Text)
}
return sb.String()
}
elvish-0.20.1/pkg/cli/modes/completion_test.go 0000664 0000000 0000000 00000003351 14570151573 0021274 0 ustar 00root root 0000000 0000000 package modes
import (
"testing"
"src.elv.sh/pkg/cli"
. "src.elv.sh/pkg/cli/clitest"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/diag"
"src.elv.sh/pkg/ui"
)
func TestCompletion_Filter(t *testing.T) {
f := setupStartedCompletion(t)
defer f.Stop()
f.TTY.Inject(term.K('b'), term.K('a'))
f.TestTTY(t,
"'foo bar'\n", Styles,
"_________",
" COMPLETING WORD ba", Styles,
"***************** ", term.DotHere, "\n",
"foo bar", Styles,
"#######",
)
}
func TestCompletion_Accept(t *testing.T) {
f := setupStartedCompletion(t)
defer f.Stop()
f.TTY.Inject(term.K(ui.Enter))
f.TestTTY(t, "foo", term.DotHere)
}
func TestCompletion_Dismiss(t *testing.T) {
f := setupStartedCompletion(t)
defer f.Stop()
f.App.PopAddon()
f.App.Redraw()
f.TestTTY(t /* nothing */)
}
func TestNewCompletion_NoItems(t *testing.T) {
f := Setup()
defer f.Stop()
_, err := NewCompletion(f.App, CompletionSpec{Items: []CompletionItem{}})
if err != errNoCandidates {
t.Errorf("should return errNoCandidates")
}
}
func TestNewCompletion_FocusedWidgetNotCodeArea(t *testing.T) {
testFocusedWidgetNotCodeArea(t, func(app cli.App) error {
_, err := NewCompletion(app, CompletionSpec{Items: []CompletionItem{{}}})
return err
})
}
func setupStartedCompletion(t *testing.T) *Fixture {
f := Setup()
w, _ := NewCompletion(f.App, CompletionSpec{
Name: "WORD",
Replace: diag.Ranging{From: 0, To: 0},
Items: []CompletionItem{
{ToShow: ui.T("foo"), ToInsert: "foo"},
{ToShow: ui.T("foo bar", ui.FgBlue), ToInsert: "'foo bar'"},
},
})
f.App.PushAddon(w)
f.App.Redraw()
f.TestTTY(t,
"foo\n", Styles,
"___",
" COMPLETING WORD ", Styles,
"***************** ", term.DotHere, "\n",
"foo foo bar", Styles,
"+++ ///////",
)
return f
}
elvish-0.20.1/pkg/cli/modes/filter_spec.go 0000664 0000000 0000000 00000001141 14570151573 0020356 0 ustar 00root root 0000000 0000000 package modes
import (
"strings"
"src.elv.sh/pkg/ui"
)
// FilterSpec specifies the configuration for the filter in listing modes.
type FilterSpec struct {
// Called with the filter text to get the filter predicate. If nil, the
// predicate performs substring match.
Maker func(string) func(string) bool
// Highlighter for the filter. If nil, the filter will not be highlighted.
Highlighter func(string) (ui.Text, []ui.Text)
}
func (f FilterSpec) makePredicate(p string) func(string) bool {
if f.Maker == nil {
return func(s string) bool { return strings.Contains(s, p) }
}
return f.Maker(p)
}
elvish-0.20.1/pkg/cli/modes/histlist.go 0000664 0000000 0000000 00000005414 14570151573 0017731 0 ustar 00root root 0000000 0000000 package modes
import (
"fmt"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/store/storedefs"
"src.elv.sh/pkg/ui"
)
// Histlist is a mode for browsing history and selecting entries to insert. It
// is based on the ComboBox widget.
type Histlist interface {
tk.ComboBox
}
// HistlistSpec specifies the configuration for the histlist mode.
type HistlistSpec struct {
// Key bindings.
Bindings tk.Bindings
// AllCmds is called to retrieve all commands.
AllCmds func() ([]storedefs.Cmd, error)
// Dedup is called to determine whether deduplication should be done.
// Defaults to true if unset.
Dedup func() bool
// Configuration for the filter.
Filter FilterSpec
// RPrompt of the code area (first row of the widget).
CodeAreaRPrompt func() ui.Text
}
// NewHistlist creates a new histlist mode.
func NewHistlist(app cli.App, spec HistlistSpec) (Histlist, error) {
codeArea, err := FocusedCodeArea(app)
if err != nil {
return nil, err
}
if spec.AllCmds == nil {
return nil, errNoHistoryStore
}
if spec.Dedup == nil {
spec.Dedup = func() bool { return true }
}
cmds, err := spec.AllCmds()
if err != nil {
return nil, fmt.Errorf("db error: %v", err.Error())
}
last := map[string]int{}
for i, cmd := range cmds {
last[cmd.Text] = i
}
cmdItems := histlistItems{cmds, last}
w := tk.NewComboBox(tk.ComboBoxSpec{
CodeArea: tk.CodeAreaSpec{
Prompt: func() ui.Text {
content := " HISTORY "
if spec.Dedup() {
content += "(dedup on) "
}
return modeLine(content, true)
},
RPrompt: spec.CodeAreaRPrompt,
Highlighter: spec.Filter.Highlighter,
},
ListBox: tk.ListBoxSpec{
Bindings: spec.Bindings,
OnAccept: func(it tk.Items, i int) {
text := it.(histlistItems).entries[i].Text
codeArea.MutateState(func(s *tk.CodeAreaState) {
buf := &s.Buffer
if buf.Content == "" {
buf.InsertAtDot(text)
} else {
buf.InsertAtDot("\n" + text)
}
})
app.PopAddon()
},
},
OnFilter: func(w tk.ComboBox, p string) {
it := cmdItems.filter(spec.Filter.makePredicate(p), spec.Dedup())
w.ListBox().Reset(it, it.Len()-1)
},
})
return w, nil
}
type histlistItems struct {
entries []storedefs.Cmd
last map[string]int
}
func (it histlistItems) filter(p func(string) bool, dedup bool) histlistItems {
var filtered []storedefs.Cmd
for i, entry := range it.entries {
text := entry.Text
if dedup && it.last[text] != i {
continue
}
if p(text) {
filtered = append(filtered, entry)
}
}
return histlistItems{filtered, nil}
}
func (it histlistItems) Show(i int) ui.Text {
entry := it.entries[i]
// TODO: The alignment of the index works up to 10000 entries.
return ui.T(fmt.Sprintf("%4d %s", entry.Seq, entry.Text))
}
func (it histlistItems) Len() int { return len(it.entries) }
elvish-0.20.1/pkg/cli/modes/histlist_test.go 0000664 0000000 0000000 00000007547 14570151573 0021001 0 ustar 00root root 0000000 0000000 package modes
import (
"regexp"
"testing"
"src.elv.sh/pkg/cli"
. "src.elv.sh/pkg/cli/clitest"
"src.elv.sh/pkg/cli/histutil"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/store/storedefs"
"src.elv.sh/pkg/ui"
)
func TestNewHistlist_NoStore(t *testing.T) {
f := Setup()
defer f.Stop()
_, err := NewHistlist(f.App, HistlistSpec{})
if err != errNoHistoryStore {
t.Errorf("want errNoHistoryStore")
}
}
func TestNewHistlist_FocusedWidgetNotCodeArea(t *testing.T) {
testFocusedWidgetNotCodeArea(t, func(app cli.App) error {
st := histutil.NewMemStore("foo")
_, err := NewHistlist(app, HistlistSpec{AllCmds: st.AllCmds})
return err
})
}
type faultyStore struct{}
func (s faultyStore) AllCmds() ([]storedefs.Cmd, error) { return nil, errMock }
func TestNewHistlist_StoreError(t *testing.T) {
f := Setup()
defer f.Stop()
_, err := NewHistlist(f.App, HistlistSpec{AllCmds: faultyStore{}.AllCmds})
if err.Error() != "db error: mock error" {
t.Errorf("want db error")
}
}
func TestHistlist(t *testing.T) {
f := Setup()
defer f.Stop()
st := histutil.NewMemStore(
// 0 1 2
"foo", "bar", "baz")
startHistlist(f.App, HistlistSpec{AllCmds: st.AllCmds})
// Test initial UI - last item selected
f.TestTTY(t,
"\n",
" HISTORY (dedup on) ", Styles,
"******************** ", term.DotHere, "\n",
" 0 foo\n",
" 1 bar\n",
" 2 baz ", Styles,
"++++++++++++++++++++++++++++++++++++++++++++++++++")
// Test filtering.
f.TTY.Inject(term.K('b'))
f.TestTTY(t,
"\n",
" HISTORY (dedup on) b", Styles,
"******************** ", term.DotHere, "\n",
" 1 bar\n",
" 2 baz ", Styles,
"++++++++++++++++++++++++++++++++++++++++++++++++++")
// Test accepting.
f.TTY.Inject(term.K(ui.Enter))
f.TestTTY(t, "baz", term.DotHere)
// Test accepting when there is already some text.
st.AddCmd(storedefs.Cmd{Text: "baz2"})
startHistlist(f.App, HistlistSpec{AllCmds: st.AllCmds})
f.TTY.Inject(term.K(ui.Enter))
f.TestTTY(t, "baz",
// codearea now contains newly inserted entry on a separate line
"\n", "baz2", term.DotHere)
}
func TestHistlist_Dedup(t *testing.T) {
f := Setup()
defer f.Stop()
st := histutil.NewMemStore(
// 0 1 2
"ls", "echo", "ls")
// No dedup
startHistlist(f.App,
HistlistSpec{AllCmds: st.AllCmds, Dedup: func() bool { return false }})
f.TestTTY(t,
"\n",
" HISTORY ", Styles,
"********* ", term.DotHere, "\n",
" 0 ls\n",
" 1 echo\n",
" 2 ls ", Styles,
"++++++++++++++++++++++++++++++++++++++++++++++++++")
f.App.PopAddon()
// With dedup
startHistlist(f.App,
HistlistSpec{AllCmds: st.AllCmds, Dedup: func() bool { return true }})
f.TestTTY(t,
"\n",
" HISTORY (dedup on) ", Styles,
"******************** ", term.DotHere, "\n",
" 1 echo\n",
" 2 ls ", Styles,
"++++++++++++++++++++++++++++++++++++++++++++++++++")
}
func TestHistlist_CustomFilter(t *testing.T) {
f := Setup()
defer f.Stop()
st := histutil.NewMemStore(
// 0 1 2
"vi", "elvish", "nvi")
startHistlist(f.App, HistlistSpec{
AllCmds: st.AllCmds,
Filter: FilterSpec{
Maker: func(p string) func(string) bool {
re, _ := regexp.Compile(p)
return func(s string) bool {
return re != nil && re.MatchString(s)
}
},
Highlighter: func(p string) (ui.Text, []ui.Text) {
return ui.T(p, ui.Inverse), nil
},
},
})
f.TTY.Inject(term.K('v'), term.K('i'), term.K('$'))
f.TestTTY(t,
"\n",
" HISTORY (dedup on) vi$", Styles,
"******************** +++", term.DotHere, "\n",
" 0 vi\n",
" 2 nvi ", Styles,
"++++++++++++++++++++++++++++++++++++++++++++++++++")
}
func startHistlist(app cli.App, spec HistlistSpec) {
w, err := NewHistlist(app, spec)
startMode(app, w, err)
}
elvish-0.20.1/pkg/cli/modes/histwalk.go 0000664 0000000 0000000 00000005747 14570151573 0017725 0 ustar 00root root 0000000 0000000 package modes
import (
"errors"
"fmt"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/histutil"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
)
// Histwalk is a mode for walking through history.
type Histwalk interface {
tk.Widget
// Walk to the previous entry in history.
Prev() error
// Walk to the next entry in history.
Next() error
// Update buffer with current entry. Always returns a nil error.
Accept() error
}
// HistwalkSpec specifies the configuration for the histwalk mode.
type HistwalkSpec struct {
// Key bindings.
Bindings tk.Bindings
// History store to walk.
Store histutil.Store
// Only walk through items with this prefix.
Prefix string
}
type histwalk struct {
app cli.App
attachedTo tk.CodeArea
cursor histutil.Cursor
HistwalkSpec
}
func (w *histwalk) Render(width, height int) *term.Buffer {
buf := w.render(width)
buf.TrimToLines(0, height)
return buf
}
func (w *histwalk) MaxHeight(width, height int) int {
return len(w.render(width).Lines)
}
func (w *histwalk) render(width int) *term.Buffer {
cmd, _ := w.cursor.Get()
content := modeLine(fmt.Sprintf(" HISTORY #%d ", cmd.Seq), false)
return term.NewBufferBuilder(width).WriteStyled(content).Buffer()
}
func (w *histwalk) Handle(event term.Event) bool {
handled := w.Bindings.Handle(w, event)
if handled {
return true
}
w.attachedTo.MutateState((*tk.CodeAreaState).ApplyPending)
w.app.PopAddon()
return w.attachedTo.Handle(event)
}
func (w *histwalk) Focus() bool { return false }
var errNoHistoryStore = errors.New("no history store")
// NewHistwalk creates a new Histwalk mode.
func NewHistwalk(app cli.App, cfg HistwalkSpec) (Histwalk, error) {
codeArea, err := FocusedCodeArea(app)
if err != nil {
return nil, err
}
if cfg.Store == nil {
return nil, errNoHistoryStore
}
if cfg.Bindings == nil {
cfg.Bindings = tk.DummyBindings{}
}
cursor := cfg.Store.Cursor(cfg.Prefix)
cursor.Prev()
if _, err := cursor.Get(); err != nil {
return nil, err
}
w := histwalk{app: app, attachedTo: codeArea, HistwalkSpec: cfg, cursor: cursor}
w.updatePending()
return &w, nil
}
func (w *histwalk) Prev() error {
return w.walk(histutil.Cursor.Prev, histutil.Cursor.Next)
}
func (w *histwalk) Next() error {
return w.walk(histutil.Cursor.Next, histutil.Cursor.Prev)
}
func (w *histwalk) walk(f func(histutil.Cursor), undo func(histutil.Cursor)) error {
f(w.cursor)
_, err := w.cursor.Get()
if err == nil {
w.updatePending()
} else if err == histutil.ErrEndOfHistory {
undo(w.cursor)
}
return err
}
func (w *histwalk) Dismiss() {
w.attachedTo.MutateState(func(s *tk.CodeAreaState) { s.Pending = tk.PendingCode{} })
}
func (w *histwalk) updatePending() {
cmd, _ := w.cursor.Get()
w.attachedTo.MutateState(func(s *tk.CodeAreaState) {
s.Pending = tk.PendingCode{
From: len(w.Prefix), To: len(s.Buffer.Content),
Content: cmd.Text[len(w.Prefix):],
}
})
}
func (w *histwalk) Accept() error {
w.attachedTo.MutateState((*tk.CodeAreaState).ApplyPending)
w.app.PopAddon()
return nil
}
elvish-0.20.1/pkg/cli/modes/histwalk_test.go 0000664 0000000 0000000 00000006107 14570151573 0020753 0 ustar 00root root 0000000 0000000 package modes
import (
"testing"
"src.elv.sh/pkg/cli"
. "src.elv.sh/pkg/cli/clitest"
"src.elv.sh/pkg/cli/histutil"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/ui"
)
func TestHistWalk(t *testing.T) {
f := Setup(WithSpec(func(spec *cli.AppSpec) {
spec.CodeAreaState.Buffer = tk.CodeBuffer{Content: "ls", Dot: 2}
}))
defer f.Stop()
f.App.Redraw()
buf0 := f.MakeBuffer("ls", term.DotHere)
f.TTY.TestBuffer(t, buf0)
getCfg := func() HistwalkSpec {
store := histutil.NewMemStore(
// 0 1 2 3 4 5
"echo", "ls -l", "echo a", "echo", "echo a", "ls -a")
return HistwalkSpec{
Store: store,
Prefix: "ls",
Bindings: tk.MapBindings{
term.K(ui.Up): func(w tk.Widget) { w.(Histwalk).Prev() },
term.K(ui.Down): func(w tk.Widget) { w.(Histwalk).Next() },
term.K('[', ui.Ctrl): func(tk.Widget) { f.App.PopAddon() },
},
}
}
startHistwalk(f.App, getCfg())
buf5 := f.MakeBuffer(
"ls -a", Styles,
" ___", term.DotHere, "\n",
" HISTORY #5 ", Styles,
"************",
)
f.TTY.TestBuffer(t, buf5)
f.TTY.Inject(term.K(ui.Up))
buf1 := f.MakeBuffer(
"ls -l", Styles,
" ___", term.DotHere, "\n",
" HISTORY #1 ", Styles,
"************",
)
f.TTY.TestBuffer(t, buf1)
f.TTY.Inject(term.K(ui.Down))
f.TTY.TestBuffer(t, buf5)
f.TTY.Inject(term.K('[', ui.Ctrl))
f.TTY.TestBuffer(t, buf0)
// Start over and accept.
startHistwalk(f.App, getCfg())
f.TTY.TestBuffer(t, buf5)
f.TTY.Inject(term.K(' '))
f.TestTTY(t, "ls -a ", term.DotHere)
}
func TestHistWalk_FocusedWidgetNotCodeArea(t *testing.T) {
testFocusedWidgetNotCodeArea(t, func(app cli.App) error {
store := histutil.NewMemStore("foo")
_, err := NewHistwalk(app, HistwalkSpec{Store: store})
return err
})
}
func TestHistWalk_NoWalker(t *testing.T) {
f := Setup()
defer f.Stop()
startHistwalk(f.App, HistwalkSpec{})
f.TestTTYNotes(t,
"error: no history store", Styles,
"!!!!!!")
}
func TestHistWalk_NoMatch(t *testing.T) {
f := Setup(WithSpec(func(spec *cli.AppSpec) {
spec.CodeAreaState.Buffer = tk.CodeBuffer{Content: "ls", Dot: 2}
}))
defer f.Stop()
f.App.Redraw()
buf0 := f.MakeBuffer("ls", term.DotHere)
f.TTY.TestBuffer(t, buf0)
store := histutil.NewMemStore("echo 1", "echo 2")
cfg := HistwalkSpec{Store: store, Prefix: "ls"}
startHistwalk(f.App, cfg)
// Test that an error message has been written to the notes buffer.
f.TestTTYNotes(t,
"error: end of history", Styles,
"!!!!!!")
// Test that buffer has not changed - histwalk addon is not active.
f.TTY.TestBuffer(t, buf0)
}
func TestHistWalk_FallbackHandler(t *testing.T) {
f := Setup()
defer f.Stop()
store := histutil.NewMemStore("ls")
startHistwalk(f.App, HistwalkSpec{Store: store, Prefix: ""})
f.TestTTY(t,
"ls", Styles,
"__", term.DotHere, "\n",
" HISTORY #0 ", Styles,
"************",
)
f.TTY.Inject(term.K(ui.Backspace))
f.TestTTY(t, "l", term.DotHere)
}
func startHistwalk(app cli.App, cfg HistwalkSpec) {
w, err := NewHistwalk(app, cfg)
if err != nil {
app.Notify(ErrorText(err))
return
}
app.PushAddon(w)
app.Redraw()
}
elvish-0.20.1/pkg/cli/modes/instant.go 0000664 0000000 0000000 00000004456 14570151573 0017553 0 ustar 00root root 0000000 0000000 package modes
import (
"errors"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/ui"
)
// Instant is a mode that executes code whenever it changes and shows the
// result.
type Instant interface {
tk.Widget
}
// InstantSpec specifies the configuration for the instant mode.
type InstantSpec struct {
// Key bindings.
Bindings tk.Bindings
// The function to execute code and returns the output.
Execute func(code string) ([]string, error)
}
type instant struct {
InstantSpec
attachedTo tk.CodeArea
textView tk.TextView
lastCode string
lastErr error
}
func (w *instant) Render(width, height int) *term.Buffer {
buf := w.render(width, height)
buf.TrimToLines(0, height)
return buf
}
func (w *instant) MaxHeight(width, height int) int {
return len(w.render(width, height).Lines)
}
func (w *instant) render(width, height int) *term.Buffer {
bb := term.NewBufferBuilder(width).
WriteStyled(modeLine(" INSTANT ", false)).SetDotHere()
if w.lastErr != nil {
bb.Newline().Write(w.lastErr.Error(), ui.FgRed)
}
buf := bb.Buffer()
if len(buf.Lines) < height {
bufTextView := w.textView.Render(width, height-len(buf.Lines))
buf.Extend(bufTextView, false)
}
return buf
}
func (w *instant) Focus() bool { return false }
func (w *instant) Handle(event term.Event) bool {
handled := w.Bindings.Handle(w, event)
if !handled {
handled = w.attachedTo.Handle(event)
}
w.update(false)
return handled
}
func (w *instant) update(force bool) {
code := w.attachedTo.CopyState().Buffer.Content
if code == w.lastCode && !force {
return
}
w.lastCode = code
output, err := w.Execute(code)
w.lastErr = err
if err == nil {
w.textView.MutateState(func(s *tk.TextViewState) {
*s = tk.TextViewState{Lines: output, First: 0}
})
}
}
var errExecutorIsRequired = errors.New("executor is required")
// NewInstant creates a new instant mode.
func NewInstant(app cli.App, cfg InstantSpec) (Instant, error) {
codeArea, err := FocusedCodeArea(app)
if err != nil {
return nil, err
}
if cfg.Execute == nil {
return nil, errExecutorIsRequired
}
if cfg.Bindings == nil {
cfg.Bindings = tk.DummyBindings{}
}
w := instant{
InstantSpec: cfg,
attachedTo: codeArea,
textView: tk.NewTextView(tk.TextViewSpec{Scrollable: true}),
}
w.update(true)
return &w, nil
}
elvish-0.20.1/pkg/cli/modes/instant_test.go 0000664 0000000 0000000 00000003137 14570151573 0020605 0 ustar 00root root 0000000 0000000 package modes
import (
"errors"
"testing"
"src.elv.sh/pkg/cli"
. "src.elv.sh/pkg/cli/clitest"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/ui"
)
func setupStartedInstant(t *testing.T) *Fixture {
f := Setup()
w, err := NewInstant(f.App, InstantSpec{
Execute: func(code string) ([]string, error) {
var err error
if code == "!" {
err = errors.New("error")
}
return []string{"result of", code}, err
},
})
startMode(f.App, w, err)
f.TestTTY(t,
term.DotHere, "\n",
" INSTANT \n", Styles,
"*********",
"result of\n",
"",
)
return f
}
func TestInstant_ShowsResult(t *testing.T) {
f := setupStartedInstant(t)
defer f.Stop()
f.TTY.Inject(term.K('a'))
bufA := f.MakeBuffer(
"a", term.DotHere, "\n",
" INSTANT \n", Styles,
"*********",
"result of\n",
"a",
)
f.TTY.TestBuffer(t, bufA)
f.TTY.Inject(term.K(ui.Right))
f.TTY.TestBuffer(t, bufA)
}
func TestInstant_ShowsError(t *testing.T) {
f := setupStartedInstant(t)
defer f.Stop()
f.TTY.Inject(term.K('!'))
f.TestTTY(t,
"!", term.DotHere, "\n",
" INSTANT \n", Styles,
"*********",
// Error shown.
"error\n", Styles,
"!!!!!",
// Buffer not updated.
"result of\n",
"",
)
}
func TestNewInstant_NoExecutor(t *testing.T) {
f := Setup()
_, err := NewInstant(f.App, InstantSpec{})
if err != errExecutorIsRequired {
t.Error("expect errExecutorIsRequired")
}
}
func TestNewInstant_FocusedWidgetNotCodeArea(t *testing.T) {
testFocusedWidgetNotCodeArea(t, func(app cli.App) error {
_, err := NewInstant(app, InstantSpec{
Execute: func(string) ([]string, error) { return nil, nil }})
return err
})
}
elvish-0.20.1/pkg/cli/modes/lastcmd.go 0000664 0000000 0000000 00000006203 14570151573 0017512 0 ustar 00root root 0000000 0000000 package modes
import (
"fmt"
"strconv"
"strings"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/histutil"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/ui"
)
// Lastcmd is a mode for inspecting the last command, and inserting part of all
// of it. It is based on the ComboBox widget.
type Lastcmd interface {
tk.ComboBox
}
// LastcmdSpec specifies the configuration for the lastcmd mode.
type LastcmdSpec struct {
// Key bindings.
Bindings tk.Bindings
// Store provides the source for the last command.
Store LastcmdStore
// Wordifier breaks a command into words.
Wordifier func(string) []string
}
// LastcmdStore is a subset of histutil.Store used in lastcmd mode.
type LastcmdStore interface {
Cursor(prefix string) histutil.Cursor
}
var _ = LastcmdStore(histutil.Store(nil))
// NewLastcmd creates a new lastcmd mode.
func NewLastcmd(app cli.App, cfg LastcmdSpec) (Lastcmd, error) {
codeArea, err := FocusedCodeArea(app)
if err != nil {
return nil, err
}
if cfg.Store == nil {
return nil, errNoHistoryStore
}
c := cfg.Store.Cursor("")
c.Prev()
cmd, err := c.Get()
if err != nil {
return nil, fmt.Errorf("db error: %v", err)
}
wordifier := cfg.Wordifier
if wordifier == nil {
wordifier = strings.Fields
}
cmdText := cmd.Text
words := wordifier(cmdText)
entries := make([]lastcmdEntry, len(words)+1)
entries[0] = lastcmdEntry{content: cmdText}
for i, word := range words {
entries[i+1] = lastcmdEntry{strconv.Itoa(i), strconv.Itoa(i - len(words)), word}
}
accept := func(text string) {
codeArea.MutateState(func(s *tk.CodeAreaState) {
s.Buffer.InsertAtDot(text)
})
app.PopAddon()
}
w := tk.NewComboBox(tk.ComboBoxSpec{
CodeArea: tk.CodeAreaSpec{Prompt: modePrompt(" LASTCMD ", true)},
ListBox: tk.ListBoxSpec{
Bindings: cfg.Bindings,
OnAccept: func(it tk.Items, i int) {
accept(it.(lastcmdItems).entries[i].content)
},
},
OnFilter: func(w tk.ComboBox, p string) {
items := filterLastcmdItems(entries, p)
if len(items.entries) == 1 {
accept(items.entries[0].content)
} else {
w.ListBox().Reset(items, 0)
}
},
})
return w, nil
}
type lastcmdItems struct {
negFilter bool
entries []lastcmdEntry
}
type lastcmdEntry struct {
posIndex string
negIndex string
content string
}
func filterLastcmdItems(allEntries []lastcmdEntry, p string) lastcmdItems {
if p == "" {
return lastcmdItems{false, allEntries}
}
var entries []lastcmdEntry
negFilter := strings.HasPrefix(p, "-")
for _, entry := range allEntries {
if (negFilter && strings.HasPrefix(entry.negIndex, p)) ||
(!negFilter && strings.HasPrefix(entry.posIndex, p)) {
entries = append(entries, entry)
}
}
return lastcmdItems{negFilter, entries}
}
func (it lastcmdItems) Show(i int) ui.Text {
index := ""
entry := it.entries[i]
if it.negFilter {
index = entry.negIndex
} else {
index = entry.posIndex
}
// NOTE: We now use a hardcoded width of 3 for the index, which will work as
// long as the command has less than 1000 words (when filter is positive) or
// 100 words (when filter is negative).
return ui.T(fmt.Sprintf("%3s %s", index, entry.content))
}
func (it lastcmdItems) Len() int { return len(it.entries) }
elvish-0.20.1/pkg/cli/modes/lastcmd_test.go 0000664 0000000 0000000 00000005215 14570151573 0020553 0 ustar 00root root 0000000 0000000 package modes
import (
"strings"
"testing"
"src.elv.sh/pkg/cli"
. "src.elv.sh/pkg/cli/clitest"
"src.elv.sh/pkg/cli/histutil"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/store/storedefs"
"src.elv.sh/pkg/ui"
)
func TestNewLastcmd_NoStore(t *testing.T) {
f := Setup()
defer f.Stop()
_, err := NewLastcmd(f.App, LastcmdSpec{})
if err != errNoHistoryStore {
t.Error("expect errNoHistoryStore")
}
}
func TestNewLastcmd_FocusedWidgetNotCodeArea(t *testing.T) {
testFocusedWidgetNotCodeArea(t, func(app cli.App) error {
st := histutil.NewMemStore("foo")
_, err := NewLastcmd(app, LastcmdSpec{Store: st})
return err
})
}
func TestNewLastcmd_StoreError(t *testing.T) {
f := Setup()
defer f.Stop()
db := histutil.NewFaultyInMemoryDB()
store, err := histutil.NewDBStore(db)
if err != nil {
panic(err)
}
db.SetOneOffError(errMock)
_, err = NewLastcmd(f.App, LastcmdSpec{Store: store})
if err.Error() != "db error: mock error" {
t.Error("expect db error")
}
}
func TestLastcmd(t *testing.T) {
f := Setup()
defer f.Stop()
st := histutil.NewMemStore("foo,bar,baz")
startLastcmd(f.App, LastcmdSpec{
Store: st,
Wordifier: func(cmd string) []string {
return strings.Split(cmd, ",")
},
})
// Test UI.
f.TestTTY(t,
"\n", // empty code area
" LASTCMD ", Styles,
"********* ", term.DotHere, "\n",
" foo,bar,baz \n", Styles,
"++++++++++++++++++++++++++++++++++++++++++++++++++",
" 0 foo\n",
" 1 bar\n",
" 2 baz",
)
// Test negative filtering.
f.TTY.Inject(term.K('-'))
f.TestTTY(t,
"\n", // empty code area
" LASTCMD -", Styles,
"********* ", term.DotHere, "\n",
" -3 foo \n", Styles,
"++++++++++++++++++++++++++++++++++++++++++++++++++",
" -2 bar\n",
" -1 baz",
)
// Test automatic submission.
f.TTY.Inject(term.K('2')) // -2 bar
f.TestTTY(t, "bar", term.DotHere)
// Test submission by Enter.
f.App.ActiveWidget().(tk.CodeArea).MutateState(func(s *tk.CodeAreaState) {
*s = tk.CodeAreaState{}
})
startLastcmd(f.App, LastcmdSpec{
Store: st,
Wordifier: func(cmd string) []string {
return strings.Split(cmd, ",")
},
})
f.TTY.Inject(term.K(ui.Enter))
f.TestTTY(t, "foo,bar,baz", term.DotHere)
// Default wordifier.
f.App.ActiveWidget().(tk.CodeArea).MutateState(func(s *tk.CodeAreaState) {
*s = tk.CodeAreaState{}
})
st.AddCmd(storedefs.Cmd{Text: "foo bar baz", Seq: 1})
startLastcmd(f.App, LastcmdSpec{Store: st})
f.TTY.Inject(term.K('0'))
f.TestTTY(t, "foo", term.DotHere)
}
func startLastcmd(app cli.App, spec LastcmdSpec) {
w, err := NewLastcmd(app, spec)
startMode(app, w, err)
}
elvish-0.20.1/pkg/cli/modes/listing.go 0000664 0000000 0000000 00000004417 14570151573 0017541 0 ustar 00root root 0000000 0000000 package modes
import (
"errors"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/ui"
)
// Listing is a customizable mode for browsing through a list of items. It is
// based on the ComboBox widget.
type Listing interface {
tk.ComboBox
}
// ListingSpec specifies the configuration for the listing mode.
type ListingSpec struct {
// Key bindings.
Bindings tk.Bindings
// Caption of the listing. If empty, defaults to " LISTING ".
Caption string
// A function that takes the query string and returns a list of Item's and
// the index of the Item to select. Required.
GetItems func(query string) (items []ListingItem, selected int)
// A function to call when the user has accepted the selected item. If the
// return value is true, the listing will not be closed after accepting.
// If unspecified, the Accept function default to a function that does
// nothing other than returning false.
Accept func(string)
// Whether to automatically accept when there is only one item.
AutoAccept bool
}
// ListingItem is an item to show in the listing.
type ListingItem struct {
// Passed to the Accept callback in Config.
ToAccept string
// How the item is shown.
ToShow ui.Text
}
var errGetItemsMustBeSpecified = errors.New("GetItems must be specified")
// NewListing creates a new listing mode.
func NewListing(app cli.App, spec ListingSpec) (Listing, error) {
if spec.GetItems == nil {
return nil, errGetItemsMustBeSpecified
}
if spec.Accept == nil {
spec.Accept = func(string) {}
}
if spec.Caption == "" {
spec.Caption = " LISTING "
}
accept := func(s string) {
app.PopAddon()
spec.Accept(s)
}
w := tk.NewComboBox(tk.ComboBoxSpec{
CodeArea: tk.CodeAreaSpec{
Prompt: modePrompt(spec.Caption, true),
},
ListBox: tk.ListBoxSpec{
Bindings: spec.Bindings,
OnAccept: func(it tk.Items, i int) {
accept(it.(listingItems)[i].ToAccept)
},
ExtendStyle: true,
},
OnFilter: func(w tk.ComboBox, q string) {
it, selected := spec.GetItems(q)
w.ListBox().Reset(listingItems(it), selected)
if spec.AutoAccept && len(it) == 1 {
accept(it[0].ToAccept)
}
},
})
return w, nil
}
type listingItems []ListingItem
func (it listingItems) Len() int { return len(it) }
func (it listingItems) Show(i int) ui.Text { return it[i].ToShow }
elvish-0.20.1/pkg/cli/modes/listing_test.go 0000664 0000000 0000000 00000004364 14570151573 0020601 0 ustar 00root root 0000000 0000000 package modes
import (
"testing"
"src.elv.sh/pkg/cli"
. "src.elv.sh/pkg/cli/clitest"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/ui"
)
func fooAndGreenBar(string) ([]ListingItem, int) {
return []ListingItem{{"foo", ui.T("foo")}, {"bar", ui.T("bar", ui.FgGreen)}}, 0
}
func TestListing_BasicUI(t *testing.T) {
f := Setup()
defer f.Stop()
startListing(f.App, ListingSpec{
Caption: " TEST ",
GetItems: fooAndGreenBar,
})
f.TestTTY(t,
"\n",
" TEST ", Styles,
"****** ", term.DotHere, "\n",
"foo \n", Styles,
"++++++++++++++++++++++++++++++++++++++++++++++++++",
"bar ", Styles,
"vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv",
)
}
func TestListing_Accept_ClosingListing(t *testing.T) {
f := Setup()
defer f.Stop()
startListing(f.App, ListingSpec{
GetItems: fooAndGreenBar,
Accept: func(t string) {
f.App.ActiveWidget().(tk.CodeArea).MutateState(func(s *tk.CodeAreaState) {
s.Buffer.InsertAtDot(t)
})
},
})
// foo will be selected
f.TTY.Inject(term.K('\n'))
f.TestTTY(t, "foo", term.DotHere)
}
func TestListing_Accept_DefaultNop(t *testing.T) {
f := Setup()
defer f.Stop()
startListing(f.App, ListingSpec{GetItems: fooAndGreenBar})
f.TTY.Inject(term.K('\n'))
f.TestTTY(t /* nothing */)
}
func TestListing_AutoAccept(t *testing.T) {
f := Setup()
defer f.Stop()
startListing(f.App, ListingSpec{
GetItems: func(query string) ([]ListingItem, int) {
if query == "" {
// Return two items initially.
return []ListingItem{
{"foo", ui.T("foo")}, {"bar", ui.T("bar")},
}, 0
}
return []ListingItem{{"bar", ui.T("bar")}}, 0
},
Accept: func(t string) {
f.App.ActiveWidget().(tk.CodeArea).MutateState(func(s *tk.CodeAreaState) {
s.Buffer.InsertAtDot(t)
})
},
AutoAccept: true,
})
f.TTY.Inject(term.K('a'))
f.TestTTY(t, "bar", term.DotHere)
}
func TestNewListing_NoGetItems(t *testing.T) {
f := Setup()
defer f.Stop()
_, err := NewListing(f.App, ListingSpec{})
if err != errGetItemsMustBeSpecified {
t.Error("expect errGetItemsMustBeSpecified")
}
}
func startListing(app cli.App, spec ListingSpec) {
w, err := NewListing(app, spec)
startMode(app, w, err)
}
elvish-0.20.1/pkg/cli/modes/location.go 0000664 0000000 0000000 00000011332 14570151573 0017672 0 ustar 00root root 0000000 0000000 package modes
import (
"errors"
"fmt"
"math"
"path/filepath"
"regexp"
"strings"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/fsutil"
"src.elv.sh/pkg/store/storedefs"
"src.elv.sh/pkg/ui"
)
// Location is a mode for viewing location history and changing to a selected
// directory. It is based on the ComboBox widget.
type Location interface {
tk.ComboBox
}
// LocationSpec is the configuration to start the location history feature.
type LocationSpec struct {
// Key bindings.
Bindings tk.Bindings
// Store provides the directory history and the function to change directory.
Store LocationStore
// IteratePinned specifies pinned directories by calling the given function
// with all pinned directories.
IteratePinned func(func(string))
// IterateHidden specifies hidden directories by calling the given function
// with all hidden directories.
IterateHidden func(func(string))
// IterateWorksapce specifies workspace configuration.
IterateWorkspaces LocationWSIterator
// Configuration for the filter.
Filter FilterSpec
}
// LocationStore defines the interface for interacting with the directory history.
type LocationStore interface {
Dirs(blacklist map[string]struct{}) ([]storedefs.Dir, error)
Chdir(dir string) error
Getwd() (string, error)
}
// A special score for pinned directories.
var pinnedScore = math.Inf(1)
var errNoDirectoryHistoryStore = errors.New("no directory history store")
// NewLocation creates a new location mode.
func NewLocation(app cli.App, cfg LocationSpec) (Location, error) {
if cfg.Store == nil {
return nil, errNoDirectoryHistoryStore
}
dirs := []storedefs.Dir{}
blacklist := map[string]struct{}{}
wsKind, wsRoot := "", ""
if cfg.IteratePinned != nil {
cfg.IteratePinned(func(s string) {
blacklist[s] = struct{}{}
dirs = append(dirs, storedefs.Dir{Score: pinnedScore, Path: s})
})
}
if cfg.IterateHidden != nil {
cfg.IterateHidden(func(s string) { blacklist[s] = struct{}{} })
}
wd, err := cfg.Store.Getwd()
if err == nil {
blacklist[wd] = struct{}{}
if cfg.IterateWorkspaces != nil {
wsKind, wsRoot = cfg.IterateWorkspaces.Parse(wd)
}
}
storedDirs, err := cfg.Store.Dirs(blacklist)
if err != nil {
return nil, fmt.Errorf("db error: %v", err)
}
for _, dir := range storedDirs {
if filepath.IsAbs(dir.Path) {
dirs = append(dirs, dir)
} else if wsKind != "" && hasPathPrefix(dir.Path, wsKind) {
dirs = append(dirs, dir)
}
}
l := locationList{dirs}
w := tk.NewComboBox(tk.ComboBoxSpec{
CodeArea: tk.CodeAreaSpec{
Prompt: modePrompt(" LOCATION ", true),
Highlighter: cfg.Filter.Highlighter,
},
ListBox: tk.ListBoxSpec{
Bindings: cfg.Bindings,
OnAccept: func(it tk.Items, i int) {
path := it.(locationList).dirs[i].Path
if strings.HasPrefix(path, wsKind) {
path = wsRoot + path[len(wsKind):]
}
err := cfg.Store.Chdir(path)
if err != nil {
app.Notify(ErrorText(err))
}
app.PopAddon()
},
},
OnFilter: func(w tk.ComboBox, p string) {
w.ListBox().Reset(l.filter(cfg.Filter.makePredicate(p)), 0)
},
})
return w, nil
}
func hasPathPrefix(path, prefix string) bool {
return path == prefix ||
strings.HasPrefix(path, prefix+string(filepath.Separator))
}
// LocationWSIterator is a function that iterates all workspaces by calling
// the passed function with the name and pattern of each kind of workspace.
// Iteration should stop when the called function returns false.
type LocationWSIterator func(func(kind, pattern string) bool)
// Parse returns whether the path matches any kind of workspace. If there is
// a match, it returns the kind of the workspace and the root. It there is no
// match, it returns "", "".
func (ws LocationWSIterator) Parse(path string) (kind, root string) {
var foundKind, foundRoot string
ws(func(kind, pattern string) bool {
if !strings.HasPrefix(pattern, "^") {
pattern = "^" + pattern
}
re, err := regexp.Compile(pattern)
if err != nil {
// TODO(xiaq): Surface the error.
return true
}
if root := re.FindString(path); root != "" {
foundKind, foundRoot = kind, root
return false
}
return true
})
return foundKind, foundRoot
}
type locationList struct {
dirs []storedefs.Dir
}
func (l locationList) filter(p func(string) bool) locationList {
var filteredDirs []storedefs.Dir
for _, dir := range l.dirs {
if p(fsutil.TildeAbbr(dir.Path)) {
filteredDirs = append(filteredDirs, dir)
}
}
return locationList{filteredDirs}
}
func (l locationList) Show(i int) ui.Text {
return ui.T(fmt.Sprintf("%s %s",
showScore(l.dirs[i].Score), fsutil.TildeAbbr(l.dirs[i].Path)))
}
func (l locationList) Len() int { return len(l.dirs) }
func showScore(f float64) string {
if f == pinnedScore {
return " *"
}
return fmt.Sprintf("%3.0f", f)
}
elvish-0.20.1/pkg/cli/modes/location_test.go 0000664 0000000 0000000 00000013553 14570151573 0020740 0 ustar 00root root 0000000 0000000 package modes
import (
"errors"
"fmt"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"src.elv.sh/pkg/cli"
. "src.elv.sh/pkg/cli/clitest"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/store/storedefs"
"src.elv.sh/pkg/testutil"
"src.elv.sh/pkg/ui"
)
type locationStore struct {
storedDirs []storedefs.Dir
dirsError error
chdir func(dir string) error
wd string
}
func (ts locationStore) Dirs(blacklist map[string]struct{}) ([]storedefs.Dir, error) {
dirs := []storedefs.Dir{}
for _, dir := range ts.storedDirs {
if _, ok := blacklist[dir.Path]; ok {
continue
}
dirs = append(dirs, dir)
}
return dirs, ts.dirsError
}
func (ts locationStore) Chdir(dir string) error {
if ts.chdir == nil {
return nil
}
return ts.chdir(dir)
}
func (ts locationStore) Getwd() (string, error) {
return ts.wd, nil
}
func TestNewLocation_NoStore(t *testing.T) {
f := Setup()
defer f.Stop()
_, err := NewLocation(f.App, LocationSpec{})
if err != errNoDirectoryHistoryStore {
t.Error("want errNoDirectoryHistoryStore")
}
}
func TestNewLocation_StoreError(t *testing.T) {
f := Setup()
defer f.Stop()
_, err := NewLocation(f.App,
LocationSpec{Store: locationStore{dirsError: errors.New("ERROR")}})
if err.Error() != "db error: ERROR" {
t.Error("want db error")
}
}
func TestLocation_FullWorkflow(t *testing.T) {
home := testutil.InTempHome(t)
f := Setup()
defer f.Stop()
errChdir := errors.New("mock chdir error")
chdirCh := make(chan string, 100)
dirs := []storedefs.Dir{
{Path: filepath.Join(home, "go"), Score: 200},
{Path: home, Score: 100},
{Path: fixPath("/tmp/foo/bar/lorem/ipsum"), Score: 50},
}
startLocation(f.App, LocationSpec{Store: locationStore{
storedDirs: dirs,
chdir: func(dir string) error { chdirCh <- dir; return errChdir },
}})
// Test UI.
wantBuf := locationBuf(
"",
"200 "+filepath.Join("~", "go"),
"100 ~",
" 50 "+fixPath("/tmp/foo/bar/lorem/ipsum"))
f.TTY.TestBuffer(t, wantBuf)
// Test filtering.
f.TTY.Inject(term.K('f'), term.K('o'))
wantBuf = locationBuf(
"fo",
" 50 "+fixPath("/tmp/foo/bar/lorem/ipsum"))
f.TTY.TestBuffer(t, wantBuf)
// Test accepting.
f.TTY.Inject(term.K(ui.Enter))
// There should be no change to codearea after accepting.
f.TestTTY(t /* nothing */)
// Error from Chdir should be sent to notes.
f.TestTTYNotes(t,
"error: mock chdir error", Styles,
"!!!!!!")
// Chdir should be called.
wantChdir := fixPath("/tmp/foo/bar/lorem/ipsum")
select {
case got := <-chdirCh:
if got != wantChdir {
t.Errorf("Chdir called with %s, want %s", got, wantChdir)
}
case <-time.After(testutil.Scaled(time.Second)):
t.Errorf("Chdir not called")
}
}
func TestLocation_Hidden(t *testing.T) {
f := Setup()
defer f.Stop()
dirs := []storedefs.Dir{
{Path: fixPath("/usr/bin"), Score: 200},
{Path: fixPath("/usr"), Score: 100},
{Path: fixPath("/tmp"), Score: 50},
}
startLocation(f.App, LocationSpec{
Store: locationStore{storedDirs: dirs},
IterateHidden: func(f func(string)) { f(fixPath("/usr")) },
})
// Test UI.
wantBuf := locationBuf(
"",
"200 "+fixPath("/usr/bin"),
" 50 "+fixPath("/tmp"))
f.TTY.TestBuffer(t, wantBuf)
}
func TestLocation_Pinned(t *testing.T) {
f := Setup()
defer f.Stop()
dirs := []storedefs.Dir{
{Path: fixPath("/usr/bin"), Score: 200},
{Path: fixPath("/usr"), Score: 100},
{Path: fixPath("/tmp"), Score: 50},
}
startLocation(f.App, LocationSpec{
Store: locationStore{storedDirs: dirs},
IteratePinned: func(f func(string)) { f(fixPath("/home")); f(fixPath("/usr")) },
})
// Test UI.
wantBuf := locationBuf(
"",
" * "+fixPath("/home"),
" * "+fixPath("/usr"),
"200 "+fixPath("/usr/bin"),
" 50 "+fixPath("/tmp"))
f.TTY.TestBuffer(t, wantBuf)
}
func TestLocation_HideWd(t *testing.T) {
f := Setup()
defer f.Stop()
dirs := []storedefs.Dir{
{Path: fixPath("/home"), Score: 200},
{Path: fixPath("/tmp"), Score: 50},
}
startLocation(f.App, LocationSpec{Store: locationStore{storedDirs: dirs, wd: fixPath("/home")}})
// Test UI.
wantBuf := locationBuf(
"",
" 50 "+fixPath("/tmp"))
f.TTY.TestBuffer(t, wantBuf)
}
func TestLocation_Workspace(t *testing.T) {
f := Setup()
defer f.Stop()
chdir := ""
dirs := []storedefs.Dir{
{Path: fixPath("home/src"), Score: 200},
{Path: fixPath("ws1/src"), Score: 150},
{Path: fixPath("ws2/bin"), Score: 100},
{Path: fixPath("/tmp"), Score: 50},
}
startLocation(f.App, LocationSpec{
Store: locationStore{
storedDirs: dirs,
wd: fixPath("/home/elf/bin"),
chdir: func(dir string) error {
chdir = dir
return nil
},
},
IterateWorkspaces: func(f func(kind, pattern string) bool) {
if runtime.GOOS == "windows" {
// Invalid patterns are ignored.
f("ws1", `C:\\usr\\[^\\+`)
f("home", `C:\\home\\[^\\]+`)
f("ws2", `C:\\tmp\[^\]+`)
} else {
// Invalid patterns are ignored.
f("ws1", "/usr/[^/+")
f("home", "/home/[^/]+")
f("ws2", "/tmp/[^/]+")
}
},
})
wantBuf := locationBuf(
"",
"200 "+fixPath("home/src"),
" 50 "+fixPath("/tmp"))
f.TTY.TestBuffer(t, wantBuf)
f.TTY.Inject(term.K(ui.Enter))
f.TestTTY(t /* nothing */)
wantChdir := fixPath("/home/elf/src")
if chdir != wantChdir {
t.Errorf("got chdir %q, want %q", chdir, wantChdir)
}
}
func locationBuf(filter string, lines ...string) *term.Buffer {
b := term.NewBufferBuilder(50).
Newline(). // empty code area
WriteStyled(modeLine(" LOCATION ", true)).
Write(filter).SetDotHere()
for i, line := range lines {
b.Newline()
if i == 0 {
b.WriteStyled(ui.T(fmt.Sprintf("%-50s", line), ui.Inverse))
} else {
b.Write(line)
}
}
return b.Buffer()
}
func fixPath(path string) string {
if runtime.GOOS != "windows" {
return path
}
if path[0] == '/' {
path = "C:" + path
}
return strings.ReplaceAll(path, "/", "\\")
}
func startLocation(app cli.App, spec LocationSpec) {
w, err := NewLocation(app, spec)
startMode(app, w, err)
}
elvish-0.20.1/pkg/cli/modes/mode.go 0000664 0000000 0000000 00000002523 14570151573 0017010 0 ustar 00root root 0000000 0000000 // Package mode implements modes, which are widgets tailored for a specific
// task.
package modes
import (
"errors"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/ui"
)
// ErrFocusedWidgetNotCodeArea is returned when an operation requires the
// focused widget to be a code area but it is not.
var ErrFocusedWidgetNotCodeArea = errors.New("focused widget is not a code area")
// FocusedCodeArea returns a CodeArea widget if the currently focused widget is
// a CodeArea. Otherwise it returns the error ErrFocusedWidgetNotCodeArea.
func FocusedCodeArea(a cli.App) (tk.CodeArea, error) {
if w, ok := a.FocusedWidget().(tk.CodeArea); ok {
return w, nil
}
return nil, ErrFocusedWidgetNotCodeArea
}
// Returns text styled as a modeline.
func modeLine(content string, space bool) ui.Text {
t := ui.T(content, ui.Bold, ui.FgWhite, ui.BgMagenta)
if space {
t = ui.Concat(t, ui.T(" "))
}
return t
}
func modePrompt(content string, space bool) func() ui.Text {
p := modeLine(content, space)
return func() ui.Text { return p }
}
// Prompt returns a callback suitable as the prompt in the codearea of a
// mode widget.
var Prompt = modePrompt
// ErrorText returns a red "error:" followed by unstyled space and err.Error().
func ErrorText(err error) ui.Text {
return ui.Concat(ui.T("error:", ui.FgRed), ui.T(" "), ui.T(err.Error()))
}
elvish-0.20.1/pkg/cli/modes/mode_test.go 0000664 0000000 0000000 00000002412 14570151573 0020044 0 ustar 00root root 0000000 0000000 package modes
import (
"errors"
"testing"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/clitest"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/tt"
"src.elv.sh/pkg/ui"
)
var Args = tt.Args
func TestModeLine(t *testing.T) {
testModeLine(t, modeLine)
}
func TestModePrompt(t *testing.T) {
prompt := func(s string, b bool) ui.Text { return modePrompt(s, b)() }
testModeLine(t, tt.Fn(prompt).Named("prompt"))
}
func testModeLine(t *testing.T, fn any) {
tt.Test(t, fn,
Args("TEST", false).Rets(
ui.T("TEST", ui.Bold, ui.FgWhite, ui.BgMagenta)),
Args("TEST", true).Rets(
ui.Concat(
ui.T("TEST", ui.Bold, ui.FgWhite, ui.BgMagenta),
ui.T(" "))),
)
}
// Common test utilities.
var errMock = errors.New("mock error")
var withNonCodeAreaAddon = clitest.WithSpec(func(spec *cli.AppSpec) {
spec.State.Addons = []tk.Widget{tk.Label{}}
})
func startMode(app cli.App, w tk.Widget, err error) {
if w != nil {
app.PushAddon(w)
app.Redraw()
}
if err != nil {
app.Notify(ErrorText(err))
}
}
func testFocusedWidgetNotCodeArea(t *testing.T, fn func(cli.App) error) {
t.Helper()
f := clitest.Setup(withNonCodeAreaAddon)
defer f.Stop()
if err := fn(f.App); err != ErrFocusedWidgetNotCodeArea {
t.Errorf("should return ErrFocusedWidgetNotCodeArea, got %v", err)
}
}
elvish-0.20.1/pkg/cli/modes/navigation.go 0000664 0000000 0000000 00000021545 14570151573 0020230 0 ustar 00root root 0000000 0000000 package modes
import (
"os"
"sort"
"strings"
"sync"
"unicode"
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/ui"
)
type Navigation interface {
tk.Widget
// SelectedName returns the currently selected name. It returns an empty
// string if there is no selected name, which can happen if the current
// directory is empty.
SelectedName() string
// Select changes the selection.
Select(f func(tk.ListBoxState) int)
// ScrollPreview scrolls the preview.
ScrollPreview(delta int)
// Ascend ascends to the parent directory.
Ascend()
// Descend descends into the currently selected child directory.
Descend()
// MutateFiltering changes the filtering status.
MutateFiltering(f func(bool) bool)
// MutateShowHidden changes whether hidden files - files whose names start
// with ".", should be shown.
MutateShowHidden(f func(bool) bool)
}
// NavigationSpec specifieis the configuration for the navigation mode.
type NavigationSpec struct {
// Key bindings.
Bindings tk.Bindings
// Underlying filesystem.
Cursor NavigationCursor
// A function that returns the relative weights of the widths of the 3
// columns. If unspecified, the ratio is 1:3:4.
WidthRatio func() [3]int
// Configuration for the filter.
Filter FilterSpec
// RPrompt of the code area (first row of the widget).
CodeAreaRPrompt func() ui.Text
}
type navigationState struct {
Filtering bool
ShowHidden bool
}
type navigation struct {
NavigationSpec
app cli.App
attachedTo tk.CodeArea
codeArea tk.CodeArea
colView tk.ColView
lastFilter string
stateMutex sync.RWMutex
state navigationState
}
func (w *navigation) MutateState(f func(*navigationState)) {
w.stateMutex.Lock()
defer w.stateMutex.Unlock()
f(&w.state)
}
func (w *navigation) CopyState() navigationState {
w.stateMutex.RLock()
defer w.stateMutex.RUnlock()
return w.state
}
func (w *navigation) Handle(event term.Event) bool {
if w.colView.Handle(event) {
return true
}
if w.CopyState().Filtering {
if w.codeArea.Handle(event) {
filter := w.codeArea.CopyState().Buffer.Content
if filter != w.lastFilter {
w.lastFilter = filter
updateState(w, "")
}
return true
}
return false
}
return w.attachedTo.Handle(event)
}
func (w *navigation) Render(width, height int) *term.Buffer {
buf := w.codeArea.Render(width, height)
bufColView := w.colView.Render(width, height-len(buf.Lines))
buf.Extend(bufColView, false)
return buf
}
func (w *navigation) MaxHeight(width, height int) int {
return w.codeArea.MaxHeight(width, height) + w.colView.MaxHeight(width, height)
}
func (w *navigation) Focus() bool {
return w.CopyState().Filtering
}
func (w *navigation) ascend() {
// Remember the name of the current directory before ascending.
currentName := ""
current, err := w.Cursor.Current()
if err == nil {
currentName = current.Name()
}
err = w.Cursor.Ascend()
if err != nil {
w.app.Notify(ErrorText(err))
} else {
w.codeArea.MutateState(func(s *tk.CodeAreaState) {
s.Buffer = tk.CodeBuffer{}
})
w.lastFilter = ""
updateState(w, currentName)
}
}
func (w *navigation) descend() {
currentCol, ok := w.colView.CopyState().Columns[1].(tk.ListBox)
if !ok {
return
}
state := currentCol.CopyState()
if state.Items.Len() == 0 {
return
}
selected := state.Items.(fileItems)[state.Selected]
if !selected.IsDirDeep() {
return
}
err := w.Cursor.Descend(selected.Name())
if err != nil {
w.app.Notify(ErrorText(err))
} else {
w.codeArea.MutateState(func(s *tk.CodeAreaState) {
s.Buffer = tk.CodeBuffer{}
})
w.lastFilter = ""
updateState(w, "")
}
}
// NewNavigation creates a new navigation mode.
func NewNavigation(app cli.App, spec NavigationSpec) (Navigation, error) {
codeArea, err := FocusedCodeArea(app)
if err != nil {
return nil, err
}
if spec.Cursor == nil {
spec.Cursor = NewOSNavigationCursor(os.Chdir)
}
if spec.WidthRatio == nil {
spec.WidthRatio = func() [3]int { return [3]int{1, 3, 4} }
}
var w *navigation
w = &navigation{
NavigationSpec: spec,
app: app,
attachedTo: codeArea,
codeArea: tk.NewCodeArea(tk.CodeAreaSpec{
Prompt: func() ui.Text {
if w.CopyState().ShowHidden {
return modeLine(" NAVIGATING (show hidden) ", true)
}
return modeLine(" NAVIGATING ", true)
},
RPrompt: spec.CodeAreaRPrompt,
Highlighter: spec.Filter.Highlighter,
}),
colView: tk.NewColView(tk.ColViewSpec{
Bindings: spec.Bindings,
Weights: func(int) []int {
a := spec.WidthRatio()
return a[:]
},
OnLeft: func(tk.ColView) { w.ascend() },
OnRight: func(tk.ColView) { w.descend() },
}),
}
updateState(w, "")
return w, nil
}
func (w *navigation) SelectedName() string {
col, ok := w.colView.CopyState().Columns[1].(tk.ListBox)
if !ok {
return ""
}
state := col.CopyState()
if 0 <= state.Selected && state.Selected < state.Items.Len() {
return state.Items.(fileItems)[state.Selected].Name()
}
return ""
}
func updateState(w *navigation, selectName string) {
colView := w.colView
cursor := w.Cursor
filter := w.lastFilter
showHidden := w.CopyState().ShowHidden
var parentCol, currentCol tk.Widget
colView.MutateState(func(s *tk.ColViewState) {
*s = tk.ColViewState{
Columns: []tk.Widget{
tk.Empty{}, tk.Empty{}, tk.Empty{}},
FocusColumn: 1,
}
})
parent, err := cursor.Parent()
if err == nil {
parentCol = makeCol(parent, showHidden)
} else {
parentCol = makeErrCol(err)
}
current, err := cursor.Current()
if err == nil {
currentCol = makeColInner(
current,
w.Filter.makePredicate(filter),
showHidden,
func(it tk.Items, i int) {
previewCol := makeCol(it.(fileItems)[i], showHidden)
colView.MutateState(func(s *tk.ColViewState) {
s.Columns[2] = previewCol
})
})
tryToSelectName(parentCol, current.Name())
if selectName != "" {
tryToSelectName(currentCol, selectName)
}
} else {
currentCol = makeErrCol(err)
tryToSelectNothing(parentCol)
}
colView.MutateState(func(s *tk.ColViewState) {
s.Columns[0] = parentCol
s.Columns[1] = currentCol
})
}
// Selects nothing if the widget is a listbox.
func tryToSelectNothing(w tk.Widget) {
list, ok := w.(tk.ListBox)
if !ok {
return
}
list.Select(func(tk.ListBoxState) int { return -1 })
}
// Selects the item with the given name, if the widget is a listbox with
// fileItems and has such an item.
func tryToSelectName(w tk.Widget, name string) {
list, ok := w.(tk.ListBox)
if !ok {
// Do nothing
return
}
list.Select(func(state tk.ListBoxState) int {
items, ok := state.Items.(fileItems)
if !ok {
return 0
}
for i, file := range items {
if file.Name() == name {
return i
}
}
return 0
})
}
func makeCol(f NavigationFile, showHidden bool) tk.Widget {
return makeColInner(f, func(string) bool { return true }, showHidden, nil)
}
func makeColInner(f NavigationFile, filter func(string) bool, showHidden bool, onSelect func(tk.Items, int)) tk.Widget {
files, content, err := f.Read()
if err != nil {
return makeErrCol(err)
}
if files != nil {
var filtered []NavigationFile
for _, file := range files {
name := file.Name()
hidden := len(name) > 0 && name[0] == '.'
if filter(name) && (showHidden || !hidden) {
filtered = append(filtered, file)
}
}
files = filtered
sort.Slice(files, func(i, j int) bool {
return files[i].Name() < files[j].Name()
})
return tk.NewListBox(tk.ListBoxSpec{
Padding: 1, ExtendStyle: true, OnSelect: onSelect,
State: tk.ListBoxState{Items: fileItems(files)},
})
}
lines := strings.Split(sanitize(string(content)), "\n")
return tk.NewTextView(tk.TextViewSpec{
State: tk.TextViewState{Lines: lines},
Scrollable: true,
})
}
func makeErrCol(err error) tk.Widget {
return tk.Label{Content: ui.T(err.Error(), ui.FgRed)}
}
type fileItems []NavigationFile
func (it fileItems) Show(i int) ui.Text {
return it[i].ShowName()
}
func (it fileItems) Len() int { return len(it) }
func sanitize(content string) string {
// Remove unprintable characters, and replace tabs with 4 spaces.
var sb strings.Builder
for _, r := range content {
if r == '\t' {
sb.WriteString(" ")
} else if r == '\n' || unicode.IsGraphic(r) {
sb.WriteRune(r)
}
}
return sb.String()
}
func (w *navigation) Select(f func(tk.ListBoxState) int) {
if listBox, ok := w.colView.CopyState().Columns[1].(tk.ListBox); ok {
listBox.Select(f)
}
}
func (w *navigation) ScrollPreview(delta int) {
if textView, ok := w.colView.CopyState().Columns[2].(tk.TextView); ok {
textView.ScrollBy(delta)
}
}
func (w *navigation) Ascend() {
w.colView.Left()
}
func (w *navigation) Descend() {
w.colView.Right()
}
func (w *navigation) MutateFiltering(f func(bool) bool) {
w.MutateState(func(s *navigationState) { s.Filtering = f(s.Filtering) })
}
func (w *navigation) MutateShowHidden(f func(bool) bool) {
w.MutateState(func(s *navigationState) { s.ShowHidden = f(s.ShowHidden) })
updateState(w, w.SelectedName())
}
elvish-0.20.1/pkg/cli/modes/navigation_fs.go 0000664 0000000 0000000 00000011654 14570151573 0020720 0 ustar 00root root 0000000 0000000 package modes
import (
"errors"
"io"
"os"
"path/filepath"
"unicode/utf8"
"src.elv.sh/pkg/cli/lscolors"
"src.elv.sh/pkg/ui"
)
// NavigationCursor represents a cursor for navigating in a potentially virtual
// filesystem.
type NavigationCursor interface {
// Current returns a File that represents the current directory.
Current() (NavigationFile, error)
// Parent returns a File that represents the parent directory. It may return
// nil if the current directory is the root of the filesystem.
Parent() (NavigationFile, error)
// Ascend navigates to the parent directory.
Ascend() error
// Descend navigates to the named child directory.
Descend(name string) error
}
// NavigationFile represents a potentially virtual file.
type NavigationFile interface {
// Name returns the name of the file.
Name() string
// ShowName returns a styled filename.
ShowName() ui.Text
// IsDirDeep returns whether the file is itself a directory or a symlink to
// a directory.
IsDirDeep() bool
// Read returns either a list of File's if the File represents a directory,
// a (possibly incomplete) slice of bytes if the File represents a normal
// file, or an error if the File cannot be read.
Read() ([]NavigationFile, []byte, error)
}
// NewOSNavigationCursor returns a NavigationCursor backed by the OS.
func NewOSNavigationCursor(chdir func(string) error) NavigationCursor {
return osCursor{chdir, lscolors.GetColorist()}
}
type osCursor struct {
chdir func(string) error
colorist lscolors.Colorist
}
func (c osCursor) Current() (NavigationFile, error) {
abs, err := filepath.Abs(".")
if err != nil {
return nil, err
}
return file{filepath.Base(abs), abs, os.ModeDir, c.colorist}, nil
}
func (c osCursor) Parent() (NavigationFile, error) {
if abs, _ := filepath.Abs("."); abs == "/" {
return emptyDir{}, nil
}
abs, err := filepath.Abs("..")
if err != nil {
return nil, err
}
return file{filepath.Base(abs), abs, os.ModeDir, c.colorist}, nil
}
func (c osCursor) Ascend() error { return c.chdir("..") }
func (c osCursor) Descend(name string) error { return c.chdir(name) }
type emptyDir struct{}
func (emptyDir) Name() string { return "" }
func (emptyDir) ShowName() ui.Text { return nil }
func (emptyDir) IsDirDeep() bool { return true }
func (emptyDir) Read() ([]NavigationFile, []byte, error) { return []NavigationFile{}, nil, nil }
type file struct {
name string
path string
mode os.FileMode
colorist lscolors.Colorist
}
func (f file) Name() string { return f.name }
func (f file) ShowName() ui.Text {
sgrStyle := f.colorist.GetStyle(f.path)
return ui.Text{&ui.Segment{
Style: ui.StyleFromSGR(sgrStyle), Text: f.name}}
}
func (f file) IsDirDeep() bool {
if f.mode.IsDir() {
// File itself is a directory; return true and save a stat call.
return true
}
info, err := os.Stat(f.path)
return err == nil && info.IsDir()
}
const previewBytes = 64 * 1024
var (
errNamedPipe = errors.New("no preview for named pipe")
errDevice = errors.New("no preview for device file")
errSocket = errors.New("no preview for socket file")
errCharDevice = errors.New("no preview for char device")
errNonUTF8 = errors.New("no preview for non-utf8 file")
)
var specialFileModes = []struct {
mode os.FileMode
err error
}{
{os.ModeNamedPipe, errNamedPipe},
{os.ModeDevice, errDevice},
{os.ModeSocket, errSocket},
{os.ModeCharDevice, errCharDevice},
}
func (f file) Read() ([]NavigationFile, []byte, error) {
// On Unix, opening a named pipe for reading is blocking when there are no
// writers, so we need to do this check at the very beginning of this
// function.
//
// TODO: There is still a chance that the file has changed between when
// f.mode is populated and the os.Open call below, in which case the os.Open
// call can still block. This can be fixed by opening the file in async mode
// and setting a timeout on the reads. Reading the file asynchronously is
// also desirable behavior more generally for the navigation mode to remain
// usable on slower filesystems.
if f.mode&os.ModeNamedPipe != 0 {
return nil, nil, errNamedPipe
}
ff, err := os.Open(f.path)
if err != nil {
return nil, nil, err
}
defer ff.Close()
info, err := ff.Stat()
if err != nil {
return nil, nil, err
}
for _, special := range specialFileModes {
if info.Mode()&special.mode != 0 {
return nil, nil, special.err
}
}
if info.IsDir() {
infos, err := ff.Readdir(0)
if err != nil {
return nil, nil, err
}
files := make([]NavigationFile, len(infos))
for i, info := range infos {
files[i] = file{
info.Name(),
filepath.Join(f.path, info.Name()),
info.Mode(),
f.colorist,
}
}
return files, nil, err
}
var buf [previewBytes]byte
nr, err := ff.Read(buf[:])
if err != nil && err != io.EOF {
return nil, nil, err
}
content := buf[:nr]
if !utf8.Valid(content) {
return nil, nil, errNonUTF8
}
return nil, content, nil
}
elvish-0.20.1/pkg/cli/modes/navigation_fs_test.go 0000664 0000000 0000000 00000004627 14570151573 0021761 0 ustar 00root root 0000000 0000000 package modes
import (
"errors"
"strings"
"src.elv.sh/pkg/testutil"
"src.elv.sh/pkg/ui"
)
var (
errCannotCd = errors.New("cannot cd")
errNoSuchFile = errors.New("no such file")
errNoSuchDir = errors.New("no such directory")
)
type testCursor struct {
root testutil.Dir
pwd []string
currentErr, parentErr, ascendErr, descendErr error
}
func (c *testCursor) Current() (NavigationFile, error) {
if c.currentErr != nil {
return nil, c.currentErr
}
return getDirFile(c.root, c.pwd)
}
func (c *testCursor) Parent() (NavigationFile, error) {
if c.parentErr != nil {
return nil, c.parentErr
}
parent := c.pwd
if len(parent) > 0 {
parent = parent[:len(parent)-1]
}
return getDirFile(c.root, parent)
}
func (c *testCursor) Ascend() error {
if c.ascendErr != nil {
return c.ascendErr
}
if len(c.pwd) > 0 {
c.pwd = c.pwd[:len(c.pwd)-1]
}
return nil
}
func (c *testCursor) Descend(name string) error {
if c.descendErr != nil {
return c.descendErr
}
pwdCopy := append([]string{}, c.pwd...)
childPath := append(pwdCopy, name)
if _, err := getDirFile(c.root, childPath); err == nil {
c.pwd = childPath
return nil
}
return errCannotCd
}
func getFile(root testutil.Dir, path []string) (NavigationFile, error) {
var f any = root
for _, p := range path {
d, ok := f.(testutil.Dir)
if !ok {
return nil, errNoSuchFile
}
f = d[p]
}
name := ""
if len(path) > 0 {
name = path[len(path)-1]
}
return testFile{name, f}, nil
}
func getDirFile(root testutil.Dir, path []string) (NavigationFile, error) {
f, err := getFile(root, path)
if err != nil {
return nil, err
}
if !f.IsDirDeep() {
return nil, errNoSuchDir
}
return f, nil
}
type testFile struct {
name string
data any
}
func (f testFile) Name() string { return f.name }
func (f testFile) ShowName() ui.Text {
// The style matches that of LS_COLORS in the test code.
switch {
case f.IsDirDeep():
return ui.T(f.name, ui.FgBlue)
case strings.HasSuffix(f.name, ".png"):
return ui.T(f.name, ui.FgRed)
default:
return ui.T(f.name)
}
}
func (f testFile) IsDirDeep() bool {
_, ok := f.data.(testutil.Dir)
return ok
}
func (f testFile) Read() ([]NavigationFile, []byte, error) {
if dir, ok := f.data.(testutil.Dir); ok {
files := make([]NavigationFile, 0, len(dir))
for name, data := range dir {
files = append(files, testFile{name, data})
}
return files, nil, nil
}
return nil, []byte(f.data.(string)), nil
}
elvish-0.20.1/pkg/cli/modes/navigation_test.go 0000664 0000000 0000000 00000022742 14570151573 0021267 0 ustar 00root root 0000000 0000000 package modes
import (
"errors"
"testing"
"src.elv.sh/pkg/cli"
. "src.elv.sh/pkg/cli/clitest"
"src.elv.sh/pkg/cli/lscolors"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
"src.elv.sh/pkg/must"
"src.elv.sh/pkg/testutil"
"src.elv.sh/pkg/ui"
)
var testDir = testutil.Dir{
"a": "",
"d": testutil.Dir{
"d1": "content\td1\nline 2",
"d2": testutil.Dir{
"d21": "content d21",
"d22": "content d22",
"other.png": "",
},
"d3": testutil.Dir{},
".dh": "hidden",
},
"f": "",
}
func TestErrorInAscend(t *testing.T) {
f := Setup()
defer f.Stop()
c := getTestCursor()
c.ascendErr = errors.New("cannot ascend")
startNavigation(f.App, NavigationSpec{Cursor: c})
f.TTY.Inject(term.K(ui.Left))
f.TestTTYNotes(t,
"error: cannot ascend", Styles,
"!!!!!!")
}
func TestErrorInDescend(t *testing.T) {
f := Setup()
defer f.Stop()
c := getTestCursor()
c.descendErr = errors.New("cannot descend")
startNavigation(f.App, NavigationSpec{Cursor: c})
f.TTY.Inject(term.K(ui.Down))
f.TTY.Inject(term.K(ui.Right))
f.TestTTYNotes(t,
"error: cannot descend", Styles,
"!!!!!!")
}
func TestErrorInCurrent(t *testing.T) {
f := setupNav(t)
defer f.Stop()
c := getTestCursor()
c.currentErr = errors.New("ERR")
startNavigation(f.App, NavigationSpec{Cursor: c})
f.TestTTY(t,
"", term.DotHere, "\n",
" NAVIGATING \n", Styles,
"************ ",
" a ERR \n", Styles,
" !!!",
" d \n", Styles,
"////",
" f ",
)
// Test that Right does nothing.
f.TTY.Inject(term.K(ui.Right))
// We can't just test that the buffer hasn't changed, because that might
// capture the state of the buffer before the Right key is handled. Instead
// we inject a key and test the result of that instead, to ensure that the
// Right key had no effect.
f.TTY.Inject(term.K('x'))
f.TestTTY(t,
"x", term.DotHere, "\n",
" NAVIGATING \n", Styles,
"************ ",
" a ERR \n", Styles,
" !!!",
" d \n", Styles,
"////",
" f ",
)
}
func TestErrorInParent(t *testing.T) {
f := setupNav(t)
defer f.Stop()
c := getTestCursor()
c.parentErr = errors.New("ERR")
startNavigation(f.App, NavigationSpec{Cursor: c})
f.TestTTY(t,
"", term.DotHere, "\n",
" NAVIGATING \n", Styles,
"************ ",
"ERR d1 content d1\n", Styles,
"!!! ++++++++++++++",
" d2 line 2\n", Styles,
" //////////////",
" d3 ", Styles,
" //////////////",
)
}
func TestWidthRatio(t *testing.T) {
f := setupNav(t)
defer f.Stop()
c := getTestCursor()
startNavigation(f.App, NavigationSpec{
Cursor: c,
WidthRatio: func() [3]int { return [3]int{1, 1, 1} },
})
f.TestTTY(t,
"", term.DotHere, "\n",
" NAVIGATING \n", Styles,
"************ ",
" a d1 content d1\n", Styles,
" +++++++++++++",
" d d2 line 2\n", Styles,
"############ /////////////",
" f d3 ", Styles,
" /////////////",
)
}
func TestNavigation_SelectedName(t *testing.T) {
f := Setup()
defer f.Stop()
w := startNavigation(f.App, NavigationSpec{Cursor: getTestCursor()})
wantName := "d1"
if name := w.SelectedName(); name != wantName {
t.Errorf("Got name %q, want %q", name, wantName)
}
}
func TestNavigation_SelectedName_EmptyDirectory(t *testing.T) {
f := Setup()
defer f.Stop()
cursor := &testCursor{
root: testutil.Dir{"d": testutil.Dir{}},
pwd: []string{"d"}}
w := startNavigation(f.App, NavigationSpec{Cursor: cursor})
wantName := ""
if name := w.SelectedName(); name != wantName {
t.Errorf("Got name %q, want %q", name, wantName)
}
}
func TestNavigation_FakeFS(t *testing.T) {
cursor := getTestCursor()
testNavigation(t, cursor)
}
func TestNavigation_RealFS(t *testing.T) {
testutil.InTempDir(t)
testutil.ApplyDir(testDir)
must.Chdir("d")
testNavigation(t, nil)
}
func testNavigation(t *testing.T, c NavigationCursor) {
f := setupNav(t)
defer f.Stop()
w := startNavigation(f.App, NavigationSpec{Cursor: c})
// Test initial UI and file preview.
// NOTE: Buffers are named after the file that is now being selected.
d1Buf := f.MakeBuffer(
"", term.DotHere, "\n",
" NAVIGATING \n", Styles,
"************ ",
" a d1 content d1\n", Styles,
" ++++++++++++++",
" d d2 line 2\n", Styles,
"#### //////////////",
" f d3 ", Styles,
" //////////////",
)
f.TTY.TestBuffer(t, d1Buf)
// Test scrolling of preview.
w.ScrollPreview(1)
f.App.Redraw()
d1Buf2 := f.MakeBuffer(
"", term.DotHere, "\n",
" NAVIGATING \n", Styles,
"************ ",
" a d1 line 2 │\n", Styles,
" ++++++++++++++ -",
" d d2 │\n", Styles,
"#### ////////////// -",
" f d3 ", Styles,
" ////////////// X",
)
f.TTY.TestBuffer(t, d1Buf2)
// Test handling of selection change and directory preview. Also test
// LS_COLORS.
w.Select(tk.Next)
f.App.Redraw()
d2Buf := f.MakeBuffer(
"", term.DotHere, "\n",
" NAVIGATING \n", Styles,
"************ ",
" a d1 d21 \n", Styles,
" ++++++++++++++++++++",
" d d2 d22 \n", Styles,
"#### ##############",
" f d3 other.png ", Styles,
" ////////////// !!!!!!!!!!!!!!!!!!!!",
)
f.TTY.TestBuffer(t, d2Buf)
// Test handling of Descend.
w.Descend()
f.App.Redraw()
d21Buf := f.MakeBuffer(
"", term.DotHere, "\n",
" NAVIGATING \n", Styles,
"************ ",
" d1 d21 content d21\n", Styles,
" ++++++++++++++",
" d2 d22 \n", Styles,
"####",
" d3 other.png ", Styles,
"//// !!!!!!!!!!!!!!",
)
f.TTY.TestBuffer(t, d21Buf)
// Test handling of Ascend, and that the current column selects the
// directory we just ascended from, thus reverting to wantBuf1.
w.Ascend()
f.App.Redraw()
f.TTY.TestBuffer(t, d2Buf)
// Test handling of Descend on a regular file, i.e. do nothing. First move
// the cursor to d1, which is a regular file.
w.Select(tk.Prev)
f.App.Redraw()
f.TTY.TestBuffer(t, d1Buf)
// Now descend, and verify that the buffer has not changed.
w.Descend()
f.App.Redraw()
f.TTY.TestBuffer(t, d1Buf)
// Test showing hidden.
w.MutateShowHidden(func(bool) bool { return true })
f.App.Redraw()
f.TestTTY(t,
"", term.DotHere, "\n",
" NAVIGATING (show hidden) \n", Styles,
"************************** ",
" a .dh content d1\n",
" d d1 line 2\n", Styles,
"#### ++++++++++++++",
" f d2 \n", Styles,
" //////////////",
" d3 ", Styles,
" //////////////",
)
w.MutateShowHidden(func(bool) bool { return false })
// Test filtering; current column shows d1, d2, d3 before filtering, and
// only shows d2 after filtering.
w.MutateFiltering(func(bool) bool { return true })
f.TTY.Inject(term.K('2'))
dFilter2Buf := f.MakeBuffer(
"\n",
" NAVIGATING 2", Styles,
"************ ", term.DotHere, "\n",
" a d2 d21 \n", Styles,
" ############## ++++++++++++++++++++",
" d d22 \n", Styles,
"####",
" f other.png ", Styles,
" !!!!!!!!!!!!!!!!!!!!",
)
f.TTY.TestBuffer(t, dFilter2Buf)
// Unbound key while filtering is ignored.
f.TTY.Inject(term.K('a', ui.Alt))
f.TTY.TestBuffer(t, dFilter2Buf)
w.MutateFiltering(func(bool) bool { return false })
// Now move into d2, and test that the filter has been cleared when
// descending.
w.Descend()
f.App.Redraw()
f.TTY.TestBuffer(t, d21Buf)
// Apply a filter within d2.
w.MutateFiltering(func(bool) bool { return true })
f.TTY.Inject(term.K('2'))
f.TestTTY(t,
"\n",
" NAVIGATING 2", Styles,
"************ ", term.DotHere, "\n",
" d1 d21 content d21\n", Styles,
" ++++++++++++++",
" d2 d22 \n", Styles,
"####",
" d3 ", Styles,
"////",
)
w.MutateFiltering(func(bool) bool { return false })
// Ascend, and test that the filter has been cleared again when ascending.
w.Ascend()
f.App.Redraw()
f.TTY.TestBuffer(t, d2Buf)
// Now move into d3, an empty directory.
w.Select(tk.Next)
w.Descend()
f.App.Redraw()
d3NoneBuf := f.MakeBuffer(
"", term.DotHere, "\n",
" NAVIGATING \n", Styles,
"************ ",
" d1 \n",
" d2 \n", Styles,
"////",
" d3 ", Styles,
"####",
)
f.TTY.TestBuffer(t, d3NoneBuf)
// Test that selecting the previous does nothing in an empty directory.
w.Select(tk.Prev)
f.App.Redraw()
f.TTY.TestBuffer(t, d3NoneBuf)
// Test that selecting the next does nothing in an empty directory.
w.Select(tk.Next)
f.App.Redraw()
f.TTY.TestBuffer(t, d3NoneBuf)
// Test that Descend does nothing in an empty directory.
w.Descend()
f.App.Redraw()
f.TTY.TestBuffer(t, d3NoneBuf)
}
func TestNewNavigation_FocusedWidgetNotCodeArea(t *testing.T) {
testFocusedWidgetNotCodeArea(t, func(app cli.App) error {
_, err := NewNavigation(app, NavigationSpec{})
return err
})
}
func setupNav(c testutil.Cleanuper) *Fixture {
lscolors.SetTestLsColors(c)
// Use a small TTY size to make the test buffer easier to build.
return Setup(WithTTY(func(tty TTYCtrl) { tty.SetSize(6, 40) }))
}
func startNavigation(app cli.App, spec NavigationSpec) Navigation {
w, _ := NewNavigation(app, spec)
startMode(app, w, nil)
return w
}
func getTestCursor() *testCursor {
return &testCursor{root: testDir, pwd: []string{"d"}}
}
elvish-0.20.1/pkg/cli/modes/navigation_unix_test.go 0000664 0000000 0000000 00000001553 14570151573 0022327 0 ustar 00root root 0000000 0000000 //go:build unix
package modes
import (
"os"
"syscall"
"testing"
. "src.elv.sh/pkg/cli/clitest"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/must"
"src.elv.sh/pkg/testutil"
)
func TestNavigation_NoPreviewForNamedPipes(t *testing.T) {
// A previous implementation tried to call os.Open on named pipes, which can
// block indefinitely. Ensure that this no longer happens.
testutil.InTempDir(t)
must.OK(os.Mkdir("d", 0777))
must.OK(syscall.Mkfifo("d/pipe", 0666))
must.OK(os.Chdir("d"))
f := setupNav(t)
defer f.Stop()
// Use the default real FS cursor.
startNavigation(f.App, NavigationSpec{})
f.TestTTY(t,
"", term.DotHere, "\n",
" NAVIGATING \n", Styles,
"************ ",
" d pipe no preview for named\n", Styles,
"#### ++++++++++++++ !!!!!!!!!!!!!!!!!!!!",
" pipe", Styles,
" !!!!!",
)
}
elvish-0.20.1/pkg/cli/modes/stub.go 0000664 0000000 0000000 00000002131 14570151573 0017034 0 ustar 00root root 0000000 0000000 package modes
import (
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
)
// Stub is a mode that just shows a modeline and keeps the focus on the code
// area. It is mainly useful to apply some special non-default bindings.
type Stub interface {
tk.Widget
}
// StubSpec specifies the configuration for the stub mode.
type StubSpec struct {
// Key bindings.
Bindings tk.Bindings
// Name to show in the modeline.
Name string
}
type stub struct {
StubSpec
}
func (w stub) Render(width, height int) *term.Buffer {
buf := w.render(width)
buf.TrimToLines(0, height)
return buf
}
func (w stub) MaxHeight(width, height int) int {
return len(w.render(width).Lines)
}
func (w stub) render(width int) *term.Buffer {
return term.NewBufferBuilder(width).
WriteStyled(modeLine(w.Name, false)).SetDotHere().Buffer()
}
func (w stub) Handle(event term.Event) bool {
return w.Bindings.Handle(w, event)
}
func (w stub) Focus() bool {
return false
}
// NewStub creates a new Stub mode.
func NewStub(cfg StubSpec) Stub {
if cfg.Bindings == nil {
cfg.Bindings = tk.DummyBindings{}
}
return stub{cfg}
}
elvish-0.20.1/pkg/cli/modes/stub_test.go 0000664 0000000 0000000 00000001463 14570151573 0020102 0 ustar 00root root 0000000 0000000 package modes
import (
"testing"
"time"
"src.elv.sh/pkg/cli"
. "src.elv.sh/pkg/cli/clitest"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/cli/tk"
)
func TestStub_Rendering(t *testing.T) {
f := Setup()
defer f.Stop()
startStub(f.App, StubSpec{Name: " STUB "})
f.TestTTY(t,
"", term.DotHere, "\n",
" STUB ", Styles,
"******",
)
}
func TestStub_Handling(t *testing.T) {
f := Setup()
defer f.Stop()
bindingCalled := make(chan bool)
startStub(f.App, StubSpec{
Bindings: tk.MapBindings{
term.K('a'): func(tk.Widget) { bindingCalled <- true }},
})
f.TTY.Inject(term.K('a'))
select {
case <-bindingCalled:
// OK
case <-time.After(time.Second):
t.Errorf("Handler not called after 1s")
}
}
func startStub(app cli.App, spec StubSpec) {
w := NewStub(spec)
app.PushAddon(w)
app.Redraw()
}
elvish-0.20.1/pkg/cli/prompt/ 0000775 0000000 0000000 00000000000 14570151573 0015745 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/cli/prompt/prompt.go 0000664 0000000 0000000 00000006624 14570151573 0017625 0 ustar 00root root 0000000 0000000 // Package prompt provides an implementation of the cli.Prompt interface.
package prompt
import (
"os"
"sync"
"time"
"src.elv.sh/pkg/ui"
)
// Prompt implements a prompt that is executed asynchronously.
type Prompt struct {
config Config
// Working directory when prompt was last updated.
lastWd string
// Channel for update requests.
updateReq chan struct{}
// Channel on which prompt contents are delivered.
ch chan struct{}
// Last computed prompt content.
last ui.Text
// Mutex for guarding access to the last field.
lastMutex sync.RWMutex
}
// Config keeps configurations for the prompt.
type Config struct {
// The function that computes the prompt.
Compute func() ui.Text
// Function to transform stale prompts.
StaleTransform func(ui.Text) ui.Text
// Threshold for a prompt to be considered as stale.
StaleThreshold func() time.Duration
// How eager the prompt should be updated. When >= 5, updated when directory
// is changed. When >= 10, always update. Default is 5.
Eagerness func() int
}
func defaultStaleTransform(t ui.Text) ui.Text {
return ui.StyleText(t, ui.Inverse)
}
const defaultStaleThreshold = 200 * time.Millisecond
const defaultEagerness = 5
var unknownContent = ui.T("???> ")
// New makes a new prompt.
func New(cfg Config) *Prompt {
if cfg.Compute == nil {
cfg.Compute = func() ui.Text { return unknownContent }
}
if cfg.StaleTransform == nil {
cfg.StaleTransform = defaultStaleTransform
}
if cfg.StaleThreshold == nil {
cfg.StaleThreshold = func() time.Duration { return defaultStaleThreshold }
}
if cfg.Eagerness == nil {
cfg.Eagerness = func() int { return defaultEagerness }
}
p := &Prompt{
cfg,
"", make(chan struct{}, 1), make(chan struct{}, 1),
unknownContent, sync.RWMutex{}}
// TODO: Don't keep a goroutine running.
go p.loop()
return p
}
func (p *Prompt) loop() {
content := unknownContent
ch := make(chan ui.Text)
for range p.updateReq {
go func() {
ch <- p.config.Compute()
}()
select {
case <-time.After(p.config.StaleThreshold()):
// The prompt callback did not finish within the threshold. Send the
// previous content, marked as stale.
p.update(p.config.StaleTransform(content))
content = <-ch
select {
case <-p.updateReq:
// If another update is already requested by the time we finish,
// keep marking the prompt as stale. This reduces flickering.
p.update(p.config.StaleTransform(content))
p.queueUpdate()
default:
p.update(content)
}
case content = <-ch:
p.update(content)
}
}
}
// Trigger triggers an update to the prompt.
func (p *Prompt) Trigger(force bool) {
if force || p.shouldUpdate() {
p.queueUpdate()
}
}
// Get returns the current content of the prompt.
func (p *Prompt) Get() ui.Text {
p.lastMutex.RLock()
defer p.lastMutex.RUnlock()
return p.last
}
// LateUpdates returns a channel on which late updates are made available.
func (p *Prompt) LateUpdates() <-chan struct{} {
return p.ch
}
func (p *Prompt) queueUpdate() {
select {
case p.updateReq <- struct{}{}:
default:
}
}
func (p *Prompt) update(content ui.Text) {
p.lastMutex.Lock()
p.last = content
p.lastMutex.Unlock()
p.ch <- struct{}{}
}
func (p *Prompt) shouldUpdate() bool {
eagerness := p.config.Eagerness()
if eagerness >= 10 {
return true
}
if eagerness >= 5 {
wd, err := os.Getwd()
if err != nil {
wd = "error"
}
oldWd := p.lastWd
p.lastWd = wd
return wd != oldWd
}
return false
}
elvish-0.20.1/pkg/cli/prompt/prompt_test.go 0000664 0000000 0000000 00000010437 14570151573 0020661 0 ustar 00root root 0000000 0000000 package prompt
import (
"fmt"
"reflect"
"testing"
"time"
"src.elv.sh/pkg/testutil"
"src.elv.sh/pkg/ui"
)
func TestPrompt_DefaultCompute(t *testing.T) {
prompt := New(Config{})
prompt.Trigger(false)
testUpdate(t, prompt, ui.T("???> "))
}
func TestPrompt_ShowsComputedPrompt(t *testing.T) {
prompt := New(Config{
Compute: func() ui.Text { return ui.T(">>> ") }})
prompt.Trigger(false)
testUpdate(t, prompt, ui.T(">>> "))
}
func TestPrompt_StalePrompt(t *testing.T) {
compute, unblock := blockedAutoIncPrompt()
prompt := New(Config{
Compute: compute,
StaleThreshold: func() time.Duration {
return testutil.Scaled(10 * time.Millisecond)
},
})
prompt.Trigger(true)
// The compute function is blocked, so a stale version of the initial
// "unknown" prompt will be shown.
testUpdate(t, prompt, ui.T("???> ", ui.Inverse))
// The compute function will now return.
unblock()
// The returned prompt will now be used.
testUpdate(t, prompt, ui.T("1> "))
// Force a refresh.
prompt.Trigger(true)
// The compute function will now be blocked again, so after a while a stale
// version of the previous prompt will be shown.
testUpdate(t, prompt, ui.T("1> ", ui.Inverse))
// Unblock the compute function.
unblock()
// The new prompt will now be shown.
testUpdate(t, prompt, ui.T("2> "))
// Force a refresh.
prompt.Trigger(true)
// Make sure that the compute function is run and stuck.
testUpdate(t, prompt, ui.T("2> ", ui.Inverse))
// Queue another two refreshes before the compute function can return.
prompt.Trigger(true)
prompt.Trigger(true)
unblock()
// Now the new prompt should be marked stale immediately.
testUpdate(t, prompt, ui.T("3> ", ui.Inverse))
unblock()
// However, the two refreshes we requested early only trigger one
// re-computation, because they are requested while the compute function is
// stuck, so they can be safely merged.
testUpdate(t, prompt, ui.T("4> "))
}
func TestPrompt_Eagerness0(t *testing.T) {
prompt := New(Config{
Compute: autoIncPrompt(),
Eagerness: func() int { return 0 },
})
// A forced refresh is always respected.
prompt.Trigger(true)
testUpdate(t, prompt, ui.T("1> "))
// A unforced refresh is not respected.
prompt.Trigger(false)
testNoUpdate(t, prompt)
// No update even if pwd has changed.
testutil.InTempDir(t)
prompt.Trigger(false)
testNoUpdate(t, prompt)
// Only force updates are respected.
prompt.Trigger(true)
testUpdate(t, prompt, ui.T("2> "))
}
func TestPrompt_Eagerness5(t *testing.T) {
prompt := New(Config{
Compute: autoIncPrompt(),
Eagerness: func() int { return 5 },
})
// The initial trigger is respected because there was no previous pwd.
prompt.Trigger(false)
testUpdate(t, prompt, ui.T("1> "))
// No update because the pwd has not changed.
prompt.Trigger(false)
testNoUpdate(t, prompt)
// Update because the pwd has changed.
testutil.InTempDir(t)
prompt.Trigger(false)
testUpdate(t, prompt, ui.T("2> "))
}
func TestPrompt_Eagerness10(t *testing.T) {
prompt := New(Config{
Compute: autoIncPrompt(),
Eagerness: func() int { return 10 },
})
// The initial trigger is respected.
prompt.Trigger(false)
testUpdate(t, prompt, ui.T("1> "))
// Subsequent triggers, force or not, are also respected.
prompt.Trigger(false)
testUpdate(t, prompt, ui.T("2> "))
prompt.Trigger(true)
testUpdate(t, prompt, ui.T("3> "))
prompt.Trigger(false)
testUpdate(t, prompt, ui.T("4> "))
}
func blockedAutoIncPrompt() (func() ui.Text, func()) {
unblockChan := make(chan struct{})
i := 0
compute := func() ui.Text {
<-unblockChan
i++
return ui.T(fmt.Sprintf("%d> ", i))
}
unblock := func() {
unblockChan <- struct{}{}
}
return compute, unblock
}
func autoIncPrompt() func() ui.Text {
i := 0
return func() ui.Text {
i++
return ui.T(fmt.Sprintf("%d> ", i))
}
}
func testUpdate(t *testing.T, p *Prompt, wantUpdate ui.Text) {
t.Helper()
select {
case <-p.LateUpdates():
update := p.Get()
if !reflect.DeepEqual(update, wantUpdate) {
t.Errorf("got updated %v, want %v", update, wantUpdate)
}
case <-time.After(time.Second):
t.Errorf("no late update after 1 second")
}
}
func testNoUpdate(t *testing.T, p *Prompt) {
t.Helper()
select {
case update := <-p.LateUpdates():
t.Errorf("unexpected update %v", update)
case <-time.After(testutil.Scaled(10 * time.Millisecond)):
// OK
}
}
elvish-0.20.1/pkg/cli/term/ 0000775 0000000 0000000 00000000000 14570151573 0015373 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/cli/term/buffer.go 0000664 0000000 0000000 00000011011 14570151573 0017165 0 ustar 00root root 0000000 0000000 package term
import (
"fmt"
"strings"
"src.elv.sh/pkg/wcwidth"
)
// Cell is an indivisible unit on the screen. It is not necessarily 1 column
// wide.
type Cell struct {
Text string
Style string
}
// Pos is a line/column position.
type Pos struct {
Line, Col int
}
// CellsWidth returns the total width of a Cell slice.
func CellsWidth(cs []Cell) int {
w := 0
for _, c := range cs {
w += wcwidth.Of(c.Text)
}
return w
}
// CompareCells returns whether two Cell slices are equal, and when they are
// not, the first index at which they differ.
func CompareCells(r1, r2 []Cell) (bool, int) {
for i, c := range r1 {
if i >= len(r2) || c != r2[i] {
return false, i
}
}
if len(r1) < len(r2) {
return false, len(r1)
}
return true, 0
}
// Buffer reflects a continuous range of lines on the terminal.
//
// The Unix terminal API provides only awkward ways of querying the terminal
// Buffer, so we keep an internal reflection and do one-way synchronizations
// (Buffer -> terminal, and not the other way around). This requires us to
// exactly match the terminal's idea of the width of characters (wcwidth) and
// where to insert soft carriage returns, so there could be bugs.
type Buffer struct {
Width int
// Lines the content of the buffer.
Lines Lines
// Dot is what the user perceives as the cursor.
Dot Pos
}
// Lines stores multiple lines.
type Lines [][]Cell
// Line stores a single line.
type Line []Cell
// NewBuffer builds a new buffer, with one empty line.
func NewBuffer(width int) *Buffer {
return &Buffer{Width: width, Lines: [][]Cell{make([]Cell, 0, width)}}
}
// Col returns the column the cursor is in.
func (b *Buffer) Col() int {
return CellsWidth(b.Lines[len(b.Lines)-1])
}
// Cursor returns the current position of the cursor.
func (b *Buffer) Cursor() Pos {
return Pos{len(b.Lines) - 1, b.Col()}
}
// BuffersHeight computes the combined height of a number of buffers.
func BuffersHeight(bufs ...*Buffer) (l int) {
for _, buf := range bufs {
if buf != nil {
l += len(buf.Lines)
}
}
return
}
// TrimToLines trims a buffer to the lines [low, high).
func (b *Buffer) TrimToLines(low, high int) {
if low < 0 {
low = 0
}
if high > len(b.Lines) {
high = len(b.Lines)
}
for i := 0; i < low; i++ {
b.Lines[i] = nil
}
for i := high; i < len(b.Lines); i++ {
b.Lines[i] = nil
}
b.Lines = b.Lines[low:high]
b.Dot.Line -= low
if b.Dot.Line < 0 {
b.Dot.Line = 0
}
}
// Extend adds all lines from b2 to the bottom of this buffer. If moveDot is
// true, the dot is updated to match the dot of b2.
func (b *Buffer) Extend(b2 *Buffer, moveDot bool) {
if b2 != nil && b2.Lines != nil {
if moveDot {
b.Dot.Line = b2.Dot.Line + len(b.Lines)
b.Dot.Col = b2.Dot.Col
}
b.Lines = append(b.Lines, b2.Lines...)
}
}
// ExtendRight extends bb to the right. It pads each line in b to be b.Width and
// appends the corresponding line in b2 to it, making new lines when b2 has more
// lines than bb.
func (b *Buffer) ExtendRight(b2 *Buffer) {
i := 0
w := b.Width
b.Width += b2.Width
for ; i < len(b.Lines) && i < len(b2.Lines); i++ {
if w0 := CellsWidth(b.Lines[i]); w0 < w {
b.Lines[i] = append(b.Lines[i], makeSpacing(w-w0)...)
}
b.Lines[i] = append(b.Lines[i], b2.Lines[i]...)
}
for ; i < len(b2.Lines); i++ {
row := append(makeSpacing(w), b2.Lines[i]...)
b.Lines = append(b.Lines, row)
}
}
// Buffer returns itself.
func (b *Buffer) Buffer() *Buffer { return b }
// TTYString returns a string for representing the buffer on the terminal.
func (b *Buffer) TTYString() string {
if b == nil {
return "nil"
}
sb := new(strings.Builder)
fmt.Fprintf(sb, "Width = %d, Dot = (%d, %d)\n", b.Width, b.Dot.Line, b.Dot.Col)
// Top border
sb.WriteString("┌" + strings.Repeat("─", b.Width) + "┐\n")
for _, line := range b.Lines {
// Left border
sb.WriteRune('│')
// Content
lastStyle := ""
usedWidth := 0
for _, cell := range line {
if cell.Style != lastStyle {
switch {
case lastStyle == "":
sb.WriteString("\033[" + cell.Style + "m")
case cell.Style == "":
sb.WriteString("\033[m")
default:
sb.WriteString("\033[;" + cell.Style + "m")
}
lastStyle = cell.Style
}
sb.WriteString(cell.Text)
usedWidth += wcwidth.Of(cell.Text)
}
if lastStyle != "" {
sb.WriteString("\033[m")
}
if usedWidth < b.Width {
sb.WriteString("$" + strings.Repeat(" ", b.Width-usedWidth-1))
}
// Right border and newline
sb.WriteString("│\n")
}
// Bottom border
sb.WriteString("└" + strings.Repeat("─", b.Width) + "┘\n")
return sb.String()
}
elvish-0.20.1/pkg/cli/term/buffer_builder.go 0000664 0000000 0000000 00000007741 14570151573 0020712 0 ustar 00root root 0000000 0000000 package term
import (
"strings"
"src.elv.sh/pkg/ui"
"src.elv.sh/pkg/wcwidth"
)
// BufferBuilder supports building of Buffer.
type BufferBuilder struct {
Width, Col, Indent int
// EagerWrap controls whether to wrap line as soon as the cursor reaches the
// right edge of the terminal. This is not often desirable as it creates
// unneessary line breaks, but is is useful when echoing the user input.
// will otherwise
EagerWrap bool
// Lines the content of the buffer.
Lines Lines
// Dot is what the user perceives as the cursor.
Dot Pos
}
// NewBufferBuilder makes a new BufferBuilder, initially with one empty line.
func NewBufferBuilder(width int) *BufferBuilder {
return &BufferBuilder{Width: width, Lines: [][]Cell{make([]Cell, 0, width)}}
}
func (bb *BufferBuilder) Cursor() Pos {
return Pos{len(bb.Lines) - 1, bb.Col}
}
// Buffer returns a Buffer built by the BufferBuilder.
func (bb *BufferBuilder) Buffer() *Buffer {
return &Buffer{bb.Width, bb.Lines, bb.Dot}
}
func (bb *BufferBuilder) SetIndent(indent int) *BufferBuilder {
bb.Indent = indent
return bb
}
func (bb *BufferBuilder) SetEagerWrap(v bool) *BufferBuilder {
bb.EagerWrap = v
return bb
}
func (bb *BufferBuilder) setDot(dot Pos) *BufferBuilder {
bb.Dot = dot
return bb
}
func (bb *BufferBuilder) SetDotHere() *BufferBuilder {
return bb.setDot(bb.Cursor())
}
func (bb *BufferBuilder) appendLine() {
bb.Lines = append(bb.Lines, make([]Cell, 0, bb.Width))
bb.Col = 0
}
func (bb *BufferBuilder) appendCell(c Cell) {
n := len(bb.Lines)
bb.Lines[n-1] = append(bb.Lines[n-1], c)
bb.Col += wcwidth.Of(c.Text)
}
// Newline starts a newline.
func (bb *BufferBuilder) Newline() *BufferBuilder {
bb.appendLine()
if bb.Indent > 0 {
for i := 0; i < bb.Indent; i++ {
bb.appendCell(Cell{Text: " "})
}
}
return bb
}
// WriteRuneSGR writes a single rune to a buffer with an SGR style, wrapping the
// line when needed. If the rune is a control character, it will be written
// using the caret notation (like ^X) and gets the additional style of
// styleForControlChar.
func (bb *BufferBuilder) WriteRuneSGR(r rune, style string) *BufferBuilder {
if r == '\n' {
bb.Newline()
return bb
}
c := Cell{string(r), style}
if r < 0x20 || r == 0x7f {
// Always show control characters in reverse video.
if style != "" {
style = style + ";7"
} else {
style = "7"
}
c = Cell{"^" + string(r^0x40), style}
}
if bb.Col+wcwidth.Of(c.Text) > bb.Width {
bb.Newline()
bb.appendCell(c)
} else {
bb.appendCell(c)
if bb.Col == bb.Width && bb.EagerWrap {
bb.Newline()
}
}
return bb
}
// Write is equivalent to calling WriteStyled with ui.T(text, style...).
func (bb *BufferBuilder) Write(text string, ts ...ui.Styling) *BufferBuilder {
return bb.WriteStyled(ui.T(text, ts...))
}
// WriteSpaces writes w spaces with the given styles.
func (bb *BufferBuilder) WriteSpaces(w int, ts ...ui.Styling) *BufferBuilder {
return bb.Write(strings.Repeat(" ", w), ts...)
}
// DotHere is a special argument to MarkLines to mark the position of the dot.
var DotHere = struct{ x struct{} }{}
// MarkLines is like calling WriteStyled with ui.MarkLines(args...), but accepts
// an additional special parameter DotHere to mark the position of the dot.
func (bb *BufferBuilder) MarkLines(args ...any) *BufferBuilder {
for i, arg := range args {
if arg == DotHere {
return bb.WriteStyled(ui.MarkLines(args[:i]...)).
SetDotHere().WriteStyled(ui.MarkLines(args[i+1:]...))
}
}
return bb.WriteStyled(ui.MarkLines(args...))
}
// WriteStringSGR writes a string to a buffer with a SGR style.
func (bb *BufferBuilder) WriteStringSGR(text, style string) *BufferBuilder {
for _, r := range text {
bb.WriteRuneSGR(r, style)
}
return bb
}
// WriteStyled writes a styled text.
func (bb *BufferBuilder) WriteStyled(t ui.Text) *BufferBuilder {
for _, seg := range t {
bb.WriteStringSGR(seg.Text, seg.Style.SGR())
}
return bb
}
func makeSpacing(n int) []Cell {
s := make([]Cell, n)
for i := 0; i < n; i++ {
s[i].Text = " "
}
return s
}
elvish-0.20.1/pkg/cli/term/buffer_builder_test.go 0000664 0000000 0000000 00000006260 14570151573 0021744 0 ustar 00root root 0000000 0000000 package term
import (
"reflect"
"testing"
"src.elv.sh/pkg/ui"
)
var bufferBuilderWritesTests = []struct {
bb *BufferBuilder
text string
style string
want *Buffer
}{
// Writing nothing.
{NewBufferBuilder(10), "", "", &Buffer{Width: 10, Lines: Lines{Line{}}}},
// Writing a single rune.
{NewBufferBuilder(10), "a", "1",
&Buffer{Width: 10, Lines: Lines{Line{Cell{"a", "1"}}}}},
// Writing control character.
{NewBufferBuilder(10), "\033", "",
&Buffer{Width: 10, Lines: Lines{Line{Cell{"^[", "7"}}}}},
// Writing styled control character.
{NewBufferBuilder(10), "a\033b", "1",
&Buffer{Width: 10, Lines: Lines{Line{
Cell{"a", "1"},
Cell{"^[", "1;7"},
Cell{"b", "1"}}}}},
// Writing text containing a newline.
{NewBufferBuilder(10), "a\nb", "1",
&Buffer{Width: 10, Lines: Lines{
Line{Cell{"a", "1"}}, Line{Cell{"b", "1"}}}}},
// Writing text containing a newline when there is indent.
{NewBufferBuilder(10).SetIndent(2), "a\nb", "1",
&Buffer{Width: 10, Lines: Lines{
Line{Cell{"a", "1"}},
Line{Cell{" ", ""}, Cell{" ", ""}, Cell{"b", "1"}},
}}},
// Writing long text that triggers wrapping.
{NewBufferBuilder(4), "aaaab", "1",
&Buffer{Width: 4, Lines: Lines{
Line{Cell{"a", "1"}, Cell{"a", "1"}, Cell{"a", "1"}, Cell{"a", "1"}},
Line{Cell{"b", "1"}}}}},
// Writing long text that triggers wrapping when there is indent.
{NewBufferBuilder(4).SetIndent(2), "aaaab", "1",
&Buffer{Width: 4, Lines: Lines{
Line{Cell{"a", "1"}, Cell{"a", "1"}, Cell{"a", "1"}, Cell{"a", "1"}},
Line{Cell{" ", ""}, Cell{" ", ""}, Cell{"b", "1"}}}}},
// Writing long text that triggers eager wrapping.
{NewBufferBuilder(4).SetIndent(2).SetEagerWrap(true), "aaaa", "1",
&Buffer{Width: 4, Lines: Lines{
Line{Cell{"a", "1"}, Cell{"a", "1"}, Cell{"a", "1"}, Cell{"a", "1"}},
Line{Cell{" ", ""}, Cell{" ", ""}}}}},
}
// TestBufferWrites tests BufferBuilder.Writes by calling Writes on a
// BufferBuilder and see if the built Buffer matches what is expected.
func TestBufferBuilderWrites(t *testing.T) {
for _, test := range bufferBuilderWritesTests {
bb := cloneBufferBuilder(test.bb)
bb.WriteStringSGR(test.text, test.style)
buf := bb.Buffer()
if !reflect.DeepEqual(buf, test.want) {
t.Errorf("buf.writes(%q, %q) makes it %v, want %v",
test.text, test.style, buf, test.want)
}
}
}
var styles = ui.RuneStylesheet{
'-': ui.Underlined,
}
var bufferBuilderTests = []struct {
name string
builder *BufferBuilder
wantBuf *Buffer
}{
{
"MarkLines",
NewBufferBuilder(10).MarkLines(
"foo ", styles,
"-- ", DotHere, "\n",
"",
"bar",
),
&Buffer{Width: 10, Dot: Pos{0, 4}, Lines: Lines{
Line{Cell{"f", "4"}, Cell{"o", "4"}, Cell{"o", ""}, Cell{" ", ""}},
Line{Cell{"b", ""}, Cell{"a", ""}, Cell{"r", ""}},
}},
},
}
func TestBufferBuilder(t *testing.T) {
for _, test := range bufferBuilderTests {
t.Run(test.name, func(t *testing.T) {
buf := test.builder.Buffer()
if !reflect.DeepEqual(buf, test.wantBuf) {
t.Errorf("Got buf %v, want %v", buf, test.wantBuf)
}
})
}
}
func cloneBufferBuilder(bb *BufferBuilder) *BufferBuilder {
return &BufferBuilder{
bb.Width, bb.Col, bb.Indent,
bb.EagerWrap, cloneLines(bb.Lines), bb.Dot}
}
elvish-0.20.1/pkg/cli/term/buffer_test.go 0000664 0000000 0000000 00000017715 14570151573 0020245 0 ustar 00root root 0000000 0000000 package term
import (
"reflect"
"testing"
)
var cellsWidthTests = []struct {
cells []Cell
wantWidth int
}{
{[]Cell{}, 0},
{[]Cell{{"a", ""}, {"好", ""}}, 3},
}
func TestCellsWidth(t *testing.T) {
for _, test := range cellsWidthTests {
if width := CellsWidth(test.cells); width != test.wantWidth {
t.Errorf("cellsWidth(%v) = %v, want %v",
test.cells, width, test.wantWidth)
}
}
}
var makeSpacingTests = []struct {
n int
want []Cell
}{
{0, []Cell{}},
{1, []Cell{{" ", ""}}},
{4, []Cell{{" ", ""}, {" ", ""}, {" ", ""}, {" ", ""}}},
}
func TestMakeSpacing(t *testing.T) {
for _, test := range makeSpacingTests {
if got := makeSpacing(test.n); !reflect.DeepEqual(got, test.want) {
t.Errorf("makeSpacing(%v) = %v, want %v", test.n, got, test.want)
}
}
}
var compareCellsTests = []struct {
cells1 []Cell
cells2 []Cell
wantEqual bool
wantIndex int
}{
{[]Cell{}, []Cell{}, true, 0},
{[]Cell{}, []Cell{{"a", ""}}, false, 0},
{
[]Cell{{"a", ""}, {"好", ""}, {"b", ""}},
[]Cell{{"a", ""}, {"好", ""}, {"c", ""}},
false, 2,
},
{
[]Cell{{"a", ""}, {"好", ""}, {"b", ""}},
[]Cell{{"a", ""}, {"好", "1"}, {"c", ""}},
false, 1,
},
}
func TestCompareCells(t *testing.T) {
for _, test := range compareCellsTests {
equal, index := CompareCells(test.cells1, test.cells2)
if equal != test.wantEqual || index != test.wantIndex {
t.Errorf("compareCells(%v, %v) = (%v, %v), want (%v, %v)",
test.cells1, test.cells2,
equal, index, test.wantEqual, test.wantIndex)
}
}
}
var bufferCursorTests = []struct {
buf *Buffer
want Pos
}{
{
&Buffer{Width: 10, Lines: Lines{Line{}}},
Pos{0, 0},
},
{
&Buffer{Width: 10, Lines: Lines{Line{Cell{"a", ""}}, Line{Cell{"好", ""}}}},
Pos{1, 2},
},
}
func TestBufferCursor(t *testing.T) {
for _, test := range bufferCursorTests {
if p := test.buf.Cursor(); p != test.want {
t.Errorf("(%v).cursor() = %v, want %v", test.buf, p, test.want)
}
}
}
var buffersHeighTests = []struct {
buffers []*Buffer
want int
}{
{nil, 0},
{[]*Buffer{NewBuffer(10)}, 1},
{
[]*Buffer{
{Width: 10, Lines: Lines{Line{}, Line{}}},
{Width: 10, Lines: Lines{Line{}}},
{Width: 10, Lines: Lines{Line{}, Line{}}},
},
5,
},
}
func TestBuffersHeight(t *testing.T) {
for _, test := range buffersHeighTests {
if h := BuffersHeight(test.buffers...); h != test.want {
t.Errorf("buffersHeight(%v...) = %v, want %v",
test.buffers, h, test.want)
}
}
}
var bufferTrimToLinesTests = []struct {
buf *Buffer
low int
high int
want *Buffer
}{
{
&Buffer{Width: 10, Lines: Lines{
Line{Cell{"a", ""}}, Line{Cell{"b", ""}}, Line{Cell{"c", ""}}, Line{Cell{"d", ""}},
}},
0, 2,
&Buffer{Width: 10, Lines: Lines{
Line{Cell{"a", ""}}, Line{Cell{"b", ""}},
}},
},
// Negative low is treated as 0.
{
&Buffer{Width: 10, Lines: Lines{
Line{Cell{"a", ""}}, Line{Cell{"b", ""}}, Line{Cell{"c", ""}}, Line{Cell{"d", ""}},
}},
-1, 2,
&Buffer{Width: 10, Lines: Lines{
Line{Cell{"a", ""}}, Line{Cell{"b", ""}},
}},
},
// With dot.
{
&Buffer{Width: 10, Lines: Lines{
Line{Cell{"a", ""}}, Line{Cell{"b", ""}}, Line{Cell{"c", ""}}, Line{Cell{"d", ""}},
}, Dot: Pos{1, 1}},
1, 3,
&Buffer{Width: 10, Lines: Lines{
Line{Cell{"b", ""}}, Line{Cell{"c", ""}},
}, Dot: Pos{0, 1}},
},
// With dot that is going to be trimmed away.
{
&Buffer{Width: 10, Lines: Lines{
Line{Cell{"a", ""}}, Line{Cell{"b", ""}}, Line{Cell{"c", ""}}, Line{Cell{"d", ""}},
}, Dot: Pos{0, 1}},
1, 3,
&Buffer{Width: 10, Lines: Lines{
Line{Cell{"b", ""}}, Line{Cell{"c", ""}},
}, Dot: Pos{0, 1}},
},
}
func TestBufferTrimToLines(t *testing.T) {
for _, test := range bufferTrimToLinesTests {
b := cloneBuffer(test.buf)
b.TrimToLines(test.low, test.high)
if !reflect.DeepEqual(b, test.want) {
t.Errorf("buf.trimToLines(%v, %v) makes it %v, want %v",
test.low, test.high, b, test.want)
}
}
}
var bufferExtendTests = []struct {
buf *Buffer
buf2 *Buffer
moveDot bool
want *Buffer
}{
{
&Buffer{Width: 10, Lines: Lines{
Line{Cell{"a", ""}}, Line{Cell{"b", ""}}}},
&Buffer{Width: 11, Lines: Lines{
Line{Cell{"c", ""}}, Line{Cell{"d", ""}}}},
false,
&Buffer{Width: 10, Lines: Lines{
Line{Cell{"a", ""}}, Line{Cell{"b", ""}},
Line{Cell{"c", ""}}, Line{Cell{"d", ""}}}},
},
// Moving dot.
{
&Buffer{Width: 10, Lines: Lines{
Line{Cell{"a", ""}}, Line{Cell{"b", ""}}}},
&Buffer{
Width: 11,
Lines: Lines{Line{Cell{"c", ""}}, Line{Cell{"d", ""}}},
Dot: Pos{1, 1},
},
true,
&Buffer{
Width: 10,
Lines: Lines{
Line{Cell{"a", ""}}, Line{Cell{"b", ""}},
Line{Cell{"c", ""}}, Line{Cell{"d", ""}}},
Dot: Pos{3, 1},
},
},
}
func TestBufferExtend(t *testing.T) {
for _, test := range bufferExtendTests {
buf := cloneBuffer(test.buf)
buf.Extend(test.buf2, test.moveDot)
if !reflect.DeepEqual(buf, test.want) {
t.Errorf("buf.extend(%v, %v) makes it %v, want %v",
test.buf2, test.moveDot, buf, test.want)
}
}
}
var bufferExtendRightTests = []struct {
buf *Buffer
buf2 *Buffer
want *Buffer
}{
// No padding, equal height.
{
&Buffer{Width: 1, Lines: Lines{Line{Cell{"a", ""}}, Line{Cell{"b", ""}}}},
&Buffer{Width: 1, Lines: Lines{Line{Cell{"c", ""}}, Line{Cell{"d", ""}}}},
&Buffer{Width: 2, Lines: Lines{
Line{Cell{"a", ""}, Cell{"c", ""}},
Line{Cell{"b", ""}, Cell{"d", ""}}}},
},
// With padding, equal height.
{
&Buffer{Width: 2, Lines: Lines{Line{Cell{"a", ""}}, Line{Cell{"b", ""}}}},
&Buffer{Width: 1, Lines: Lines{Line{Cell{"c", ""}}, Line{Cell{"d", ""}}}},
&Buffer{Width: 3, Lines: Lines{
Line{Cell{"a", ""}, Cell{" ", ""}, Cell{"c", ""}},
Line{Cell{"b", ""}, Cell{" ", ""}, Cell{"d", ""}}}},
},
// buf is higher.
{
&Buffer{Width: 1, Lines: Lines{
Line{Cell{"a", ""}}, Line{Cell{"b", ""}}, Line{Cell{"x", ""}}}},
&Buffer{Width: 1, Lines: Lines{
Line{Cell{"c", ""}}, Line{Cell{"d", ""}},
}},
&Buffer{Width: 2, Lines: Lines{
Line{Cell{"a", ""}, Cell{"c", ""}},
Line{Cell{"b", ""}, Cell{"d", ""}},
Line{Cell{"x", ""}}}},
},
// buf2 is higher.
{
&Buffer{Width: 1, Lines: Lines{Line{Cell{"a", ""}}, Line{Cell{"b", ""}}}},
&Buffer{Width: 1, Lines: Lines{
Line{Cell{"c", ""}}, Line{Cell{"d", ""}}, Line{Cell{"e", ""}},
}},
&Buffer{Width: 2, Lines: Lines{
Line{Cell{"a", ""}, Cell{"c", ""}},
Line{Cell{"b", ""}, Cell{"d", ""}},
Line{Cell{" ", ""}, Cell{"e", ""}}}},
},
}
func TestBufferExtendRight(t *testing.T) {
for _, test := range bufferExtendRightTests {
buf := cloneBuffer(test.buf)
buf.ExtendRight(test.buf2)
if !reflect.DeepEqual(buf, test.want) {
t.Errorf("buf.extendRight(%v) makes it %v, want %v",
test.buf2, buf, test.want)
}
}
}
func TestBufferBuffer(t *testing.T) {
b := NewBufferBuilder(4).Write("text").Buffer()
if b.Buffer() != b {
t.Errorf("Buffer did not return itself")
}
}
var bufferTTYStringTests = []struct {
buf *Buffer
want string
}{
{
nil,
"nil",
},
{
NewBufferBuilder(4).
Write("ABCD").
Newline().
Write("XY").
Buffer(),
"Width = 4, Dot = (0, 0)\n" +
"┌────┐\n" +
"│ABCD│\n" +
"│XY$ │\n" +
"└────┘\n",
},
{
NewBufferBuilder(4).
Write("A").SetDotHere().
WriteStringSGR("B", "1").
WriteStringSGR("C", "7").
Write("D").
Newline().
WriteStringSGR("XY", "7").
Buffer(),
"Width = 4, Dot = (0, 1)\n" +
"┌────┐\n" +
"│A\033[1mB\033[;7mC\033[mD│\n" +
"│\033[7mXY\033[m$ │\n" +
"└────┘\n",
},
}
func TestBufferTTYString(t *testing.T) {
for _, test := range bufferTTYStringTests {
ttyString := test.buf.TTYString()
if ttyString != test.want {
t.Errorf("TTYString -> %q, want %q", ttyString, test.want)
}
}
}
func cloneBuffer(b *Buffer) *Buffer {
return &Buffer{b.Width, cloneLines(b.Lines), b.Dot}
}
func cloneLines(lines Lines) Lines {
newLines := make(Lines, len(lines))
for i, line := range lines {
if line != nil {
newLines[i] = make(Line, len(line))
copy(newLines[i], line)
}
}
return newLines
}
elvish-0.20.1/pkg/cli/term/event.go 0000664 0000000 0000000 00000003020 14570151573 0017036 0 ustar 00root root 0000000 0000000 package term
import (
"src.elv.sh/pkg/ui"
)
// Event represents an event that can be read from the terminal.
type Event interface {
isEvent()
}
// KeyEvent represents a key press.
type KeyEvent ui.Key
// K constructs a new KeyEvent.
func K(r rune, mods ...ui.Mod) KeyEvent {
return KeyEvent(ui.K(r, mods...))
}
// MouseEvent represents a mouse event (either pressing or releasing).
type MouseEvent struct {
Pos
Down bool
// Number of the Button, 0-based. -1 for unknown.
Button int
Mod ui.Mod
}
// CursorPosition represents a report of the current cursor position from the
// terminal driver, usually as a response from a cursor position request.
type CursorPosition Pos
// PasteSetting indicates the start or finish of pasted text.
type PasteSetting bool
// FatalErrorEvent represents an error that affects the Reader's ability to
// continue reading events. After sending a FatalError, the Reader makes no more
// attempts at continuing to read events and wait for Stop to be called.
type FatalErrorEvent struct{ Err error }
// NonfatalErrorEvent represents an error that can be gradually recovered. After
// sending a NonfatalError, the Reader will continue to read events. Note that
// one anamoly in the terminal might cause multiple NonfatalError events to be
// sent.
type NonfatalErrorEvent struct{ Err error }
func (KeyEvent) isEvent() {}
func (MouseEvent) isEvent() {}
func (CursorPosition) isEvent() {}
func (PasteSetting) isEvent() {}
func (FatalErrorEvent) isEvent() {}
func (NonfatalErrorEvent) isEvent() {}
elvish-0.20.1/pkg/cli/term/file_reader_unix.go 0000664 0000000 0000000 00000003111 14570151573 0021222 0 ustar 00root root 0000000 0000000 //go:build unix
package term
import (
"io"
"os"
"sync"
"syscall"
"time"
"src.elv.sh/pkg/sys/eunix"
)
// A helper for reading from a file.
type fileReader interface {
byteReaderWithTimeout
// Stop stops any outstanding read call. It blocks until the read returns.
Stop() error
// Close releases new resources allocated for the fileReader. It does not
// close the underlying file.
Close()
}
func newFileReader(file *os.File) (fileReader, error) {
rStop, wStop, err := os.Pipe()
if err != nil {
return nil, err
}
return &bReader{file: file, rStop: rStop, wStop: wStop}, nil
}
type bReader struct {
file *os.File
rStop *os.File
wStop *os.File
// A mutex that is held when Read is in process.
mutex sync.Mutex
}
func (r *bReader) ReadByteWithTimeout(timeout time.Duration) (byte, error) {
r.mutex.Lock()
defer r.mutex.Unlock()
for {
ready, err := eunix.WaitForRead(timeout, r.file, r.rStop)
if err != nil {
if err == syscall.EINTR {
continue
}
return 0, err
}
if ready[1] {
var b [1]byte
r.rStop.Read(b[:])
return 0, ErrStopped
}
if !ready[0] {
return 0, errTimeout
}
var b [1]byte
nr, err := r.file.Read(b[:])
if err != nil {
return 0, err
}
if nr != 1 {
return 0, io.ErrNoProgress
}
return b[0], nil
}
}
func (r *bReader) Stop() error {
_, err := r.wStop.Write([]byte{'q'})
r.mutex.Lock()
//lint:ignore SA2001 We only lock the mutex to make sure that
// ReadByteWithTimeout has exited, so we unlock it immediately.
r.mutex.Unlock()
return err
}
func (r *bReader) Close() {
r.rStop.Close()
r.wStop.Close()
}
elvish-0.20.1/pkg/cli/term/file_reader_unix_test.go 0000664 0000000 0000000 00000003204 14570151573 0022264 0 ustar 00root root 0000000 0000000 //go:build unix
package term
import (
"fmt"
"io"
"os"
"testing"
"time"
"src.elv.sh/pkg/must"
"src.elv.sh/pkg/testutil"
)
func TestFileReader_ReadByteWithTimeout(t *testing.T) {
r, w, cleanup := setupFileReader()
defer cleanup()
content := []byte("0123456789")
w.Write(content)
// Test successful ReadByteWithTimeout calls.
for i := 0; i < len(content); i++ {
t.Run(fmt.Sprintf("byte %d", i), func(t *testing.T) {
b, err := r.ReadByteWithTimeout(-1)
if err != nil {
t.Errorf("got err %v, want nil", err)
}
if b != content[i] {
t.Errorf("got byte %q, want %q", b, content[i])
}
})
}
}
func TestFileReader_ReadByteWithTimeout_EOF(t *testing.T) {
r, w, cleanup := setupFileReader()
defer cleanup()
w.Close()
_, err := r.ReadByteWithTimeout(-1)
if err != io.EOF {
t.Errorf("got byte %v, want %v", err, io.EOF)
}
}
func TestFileReader_ReadByteWithTimeout_Timeout(t *testing.T) {
r, _, cleanup := setupFileReader()
defer cleanup()
_, err := r.ReadByteWithTimeout(testutil.Scaled(time.Millisecond))
if err != errTimeout {
t.Errorf("got err %v, want %v", err, errTimeout)
}
}
func TestFileReader_Stop(t *testing.T) {
r, _, cleanup := setupFileReader()
defer cleanup()
errCh := make(chan error, 1)
go func() {
_, err := r.ReadByteWithTimeout(-1)
errCh <- err
}()
r.Stop()
if err := <-errCh; err != ErrStopped {
t.Errorf("got err %v, want %v", err, ErrStopped)
}
}
func setupFileReader() (reader fileReader, writer *os.File, cleanup func()) {
pr, pw := must.Pipe()
r, err := newFileReader(pr)
if err != nil {
panic(err)
}
return r, pw, func() {
r.Close()
pr.Close()
pw.Close()
}
}
elvish-0.20.1/pkg/cli/term/read_rune.go 0000664 0000000 0000000 00000002026 14570151573 0017666 0 ustar 00root root 0000000 0000000 //go:build unix
package term
import (
"time"
)
type byteReaderWithTimeout interface {
// ReadByteWithTimeout reads a single byte with a timeout. A negative
// timeout means no timeout.
ReadByteWithTimeout(timeout time.Duration) (byte, error)
}
const badRune = '\ufffd'
var utf8SeqTimeout = 10 * time.Millisecond
// Reads a rune from the reader. The timeout applies to the first byte; a
// negative value means no timeout.
func readRune(rd byteReaderWithTimeout, timeout time.Duration) (rune, error) {
leader, err := rd.ReadByteWithTimeout(timeout)
if err != nil {
return badRune, err
}
var r rune
pending := 0
switch {
case leader>>7 == 0:
r = rune(leader)
case leader>>5 == 0x6:
r = rune(leader & 0x1f)
pending = 1
case leader>>4 == 0xe:
r = rune(leader & 0xf)
pending = 2
case leader>>3 == 0x1e:
r = rune(leader & 0x7)
pending = 3
}
for i := 0; i < pending; i++ {
b, err := rd.ReadByteWithTimeout(utf8SeqTimeout)
if err != nil {
return badRune, err
}
r = r<<6 + rune(b&0x3f)
}
return r, nil
}
elvish-0.20.1/pkg/cli/term/read_rune_test.go 0000664 0000000 0000000 00000002257 14570151573 0020733 0 ustar 00root root 0000000 0000000 //go:build unix
package term
import "testing"
// TODO(xiaq): Do not depend on Unix for this test.
var contents = []string{
"English",
"Ελληνικά",
"你好 こんにちは",
"𐌰𐌱",
}
func TestReadRune(t *testing.T) {
for _, content := range contents {
t.Run(content, func(t *testing.T) {
rd, w, cleanup := setupFileReader()
defer cleanup()
w.Write([]byte(content))
for _, wantRune := range content {
r, err := readRune(rd, 0)
if r != wantRune {
t.Errorf("got rune %q, want %q", r, wantRune)
}
if err != nil {
t.Errorf("got err %v, want nil", err)
}
}
})
}
}
func TestReadRune_ErrorAtFirstByte(t *testing.T) {
rd, _, cleanup := setupFileReader()
defer cleanup()
r, err := readRune(rd, 0)
if r != '\ufffd' {
t.Errorf("got rune %q, want %q", r, '\ufffd')
}
if err == nil {
t.Errorf("got err %v, want non-nil", err)
}
}
func TestReadRune_ErrorAtNonFirstByte(t *testing.T) {
rd, w, cleanup := setupFileReader()
defer cleanup()
w.Write([]byte{0xe4})
r, err := readRune(rd, 0)
if r != '\ufffd' {
t.Errorf("got rune %q, want %q", r, '\ufffd')
}
if err == nil {
t.Errorf("got err %v, want non-nil", err)
}
}
elvish-0.20.1/pkg/cli/term/reader.go 0000664 0000000 0000000 00000002733 14570151573 0017171 0 ustar 00root root 0000000 0000000 package term
import (
"errors"
"fmt"
"os"
)
// Reader reads events from the terminal.
type Reader interface {
// ReadEvent reads a single event from the terminal.
ReadEvent() (Event, error)
// ReadRawEvent reads a single raw event from the terminal. The concept of
// raw events is applicable where terminal events are represented as escape
// sequences sequences, such as most modern Unix terminal emulators. If
// the concept is not applicable, such as in the Windows console, it is
// equivalent to ReadEvent.
ReadRawEvent() (Event, error)
// Close releases resources associated with the Reader. Any outstanding
// ReadEvent or ReadRawEvent call will be aborted, returning ErrStopped.
Close()
}
// ErrStopped is returned by Reader when Close is called during a ReadEvent or
// ReadRawEvent method.
var ErrStopped = errors.New("stopped")
var errTimeout = errors.New("timed out")
type seqError struct {
msg string
seq string
}
func (err seqError) Error() string {
return fmt.Sprintf("%s: %q", err.msg, err.seq)
}
// NewReader creates a new Reader on the given terminal file.
//
// TODO: NewReader should return an error as well. Right now failure to
// initialize Reader panics.
func NewReader(f *os.File) Reader {
return newReader(f)
}
// IsReadErrorRecoverable returns whether an error returned by Reader is
// recoverable.
func IsReadErrorRecoverable(err error) bool {
if _, ok := err.(seqError); ok {
return true
}
return err == ErrStopped || err == errTimeout
}
elvish-0.20.1/pkg/cli/term/reader_test.go 0000664 0000000 0000000 00000000477 14570151573 0020233 0 ustar 00root root 0000000 0000000 package term
import (
"errors"
"testing"
"src.elv.sh/pkg/tt"
)
var Args = tt.Args
func TestIsReadErrorRecoverable(t *testing.T) {
tt.Test(t, IsReadErrorRecoverable,
Args(seqError{}).Rets(true),
Args(ErrStopped).Rets(true),
Args(errTimeout).Rets(true),
Args(errors.New("other error")).Rets(false),
)
}
elvish-0.20.1/pkg/cli/term/reader_unix.go 0000664 0000000 0000000 00000027474 14570151573 0020245 0 ustar 00root root 0000000 0000000 //go:build unix
package term
import (
"os"
"time"
"src.elv.sh/pkg/ui"
)
// reader reads terminal escape sequences and decodes them into events.
type reader struct {
fr fileReader
}
func newReader(f *os.File) *reader {
fr, err := newFileReader(f)
if err != nil {
// TODO(xiaq): Do not panic.
panic(err)
}
return &reader{fr}
}
func (rd *reader) ReadEvent() (Event, error) {
return readEvent(rd.fr)
}
func (rd *reader) ReadRawEvent() (Event, error) {
r, err := readRune(rd.fr, -1)
return K(r), err
}
func (rd *reader) Close() {
rd.fr.Stop()
rd.fr.Close()
}
// Used by readRune in readOne to signal end of current sequence.
const runeEndOfSeq rune = -1
// Timeout for bytes in escape sequences. Modern terminal emulators send escape
// sequences very fast, so 10ms is more than sufficient. SSH connections on a
// slow link might be problematic though.
var keySeqTimeout = 10 * time.Millisecond
func readEvent(rd byteReaderWithTimeout) (event Event, err error) {
var r rune
r, err = readRune(rd, -1)
if err != nil {
return
}
currentSeq := string(r)
// Attempts to read a rune within a timeout of keySeqTimeout. It returns
// runeEndOfSeq if there is any error; the caller should terminate the
// current sequence when it sees that value.
readRune :=
func() rune {
r, e := readRune(rd, keySeqTimeout)
if e != nil {
return runeEndOfSeq
}
currentSeq += string(r)
return r
}
badSeq := func(msg string) {
err = seqError{msg, currentSeq}
}
switch r {
case 0x1b: // ^[ Escape
r2 := readRune()
// According to https://unix.stackexchange.com/a/73697, rxvt and derivatives
// prepend another ESC to a CSI-style or G3-style sequence to signal Alt.
// If that happens, remember this now; it will be later picked up when parsing
// those two kinds of sequences.
//
// issue #181
hasTwoLeadingESC := false
if r2 == 0x1b {
hasTwoLeadingESC = true
r2 = readRune()
}
if r2 == runeEndOfSeq {
// TODO(xiaq): Error is swallowed.
// Nothing follows. Taken as a lone Escape.
event = KeyEvent{'[', ui.Ctrl}
break
}
switch r2 {
case '[':
// A '[' follows. CSI style function key sequence.
r = readRune()
if r == runeEndOfSeq {
event = KeyEvent{'[', ui.Alt}
return
}
nums := make([]int, 0, 2)
var starter rune
// Read an optional starter.
switch r {
case '<':
starter = r
r = readRune()
case 'M':
// Mouse event.
cb := readRune()
if cb == runeEndOfSeq {
badSeq("incomplete mouse event")
return
}
cx := readRune()
if cx == runeEndOfSeq {
badSeq("incomplete mouse event")
return
}
cy := readRune()
if cy == runeEndOfSeq {
badSeq("incomplete mouse event")
return
}
down := true
button := int(cb & 3)
if button == 3 {
down = false
button = -1
}
mod := mouseModify(int(cb))
event = MouseEvent{
Pos{int(cy) - 32, int(cx) - 32}, down, button, mod}
return
}
CSISeq:
for {
switch {
case r == ';':
nums = append(nums, 0)
case '0' <= r && r <= '9':
if len(nums) == 0 {
nums = append(nums, 0)
}
cur := len(nums) - 1
nums[cur] = nums[cur]*10 + int(r-'0')
case r == runeEndOfSeq:
// Incomplete CSI.
badSeq("incomplete CSI")
return
default: // Treat as a terminator.
break CSISeq
}
r = readRune()
}
if starter == 0 && r == 'R' {
// Cursor position report.
if len(nums) != 2 {
badSeq("bad CPR")
return
}
event = CursorPosition{nums[0], nums[1]}
} else if starter == '<' && (r == 'm' || r == 'M') {
// SGR-style mouse event.
if len(nums) != 3 {
badSeq("bad SGR mouse event")
return
}
down := r == 'M'
button := nums[0] & 3
mod := mouseModify(nums[0])
event = MouseEvent{Pos{nums[2], nums[1]}, down, button, mod}
} else if r == '~' && len(nums) == 1 && (nums[0] == 200 || nums[0] == 201) {
b := nums[0] == 200
event = PasteSetting(b)
} else {
k := parseCSI(nums, r, currentSeq)
if k == (ui.Key{}) {
badSeq("bad CSI")
} else {
if hasTwoLeadingESC {
k.Mod |= ui.Alt
}
event = KeyEvent(k)
}
}
case 'O':
// An 'O' follows. G3 style function key sequence: read one rune.
r = readRune()
if r == runeEndOfSeq {
// Nothing follows after 'O'. Taken as Alt-O.
event = KeyEvent{'O', ui.Alt}
return
}
k, ok := g3Seq[r]
if ok {
if hasTwoLeadingESC {
k.Mod |= ui.Alt
}
event = KeyEvent(k)
} else {
badSeq("bad G3")
}
default:
// Something other than '[' or 'O' follows. Taken as an
// Alt-modified key, possibly also modified by Ctrl.
k := ctrlModify(r2)
k.Mod |= ui.Alt
event = KeyEvent(k)
}
default:
event = KeyEvent(ctrlModify(r))
}
return
}
// Determines whether a rune corresponds to a Ctrl-modified key and returns the
// ui.Key the rune represents.
func ctrlModify(r rune) ui.Key {
switch r {
// TODO(xiaq): Are the following special cases universal?
case 0x0:
return ui.K('`', ui.Ctrl) // ^@
case 0x1e:
return ui.K('6', ui.Ctrl) // ^^
case 0x1f:
return ui.K('/', ui.Ctrl) // ^_
case ui.Tab, ui.Enter, ui.Backspace: // ^I ^J ^?
// Ambiguous Ctrl keys; prefer the non-Ctrl form as they are more likely.
return ui.K(r)
default:
// Regular ui.Ctrl sequences.
if 0x1 <= r && r <= 0x1d {
return ui.K(r+0x40, ui.Ctrl)
}
}
return ui.K(r)
}
// Tables for key sequences. Comments document which terminal emulators are
// known to generate which sequences. The terminal emulators tested are
// categorized into xterm (including actual xterm, libvte-based terminals,
// Konsole and Terminal.app unless otherwise noted), urxvt, tmux.
// G3-style key sequences: \eO followed by exactly one character. For instance,
// \eOP is F1. These are pretty limited in that they cannot be extended to
// support modifier keys, other than a leading \e for Alt (e.g. \e\eOP is
// Alt-F1). Terminals that send G3-style key sequences typically switch to
// sending a CSI-style key sequence when a non-Alt modifier key is pressed.
var g3Seq = map[rune]ui.Key{
// xterm, tmux -- only in Vim, depends on termios setting?
// NOTE(xiaq): According to urxvt's manpage, \eO[ABCD] sequences are used for
// Ctrl-Shift-modified arrow keys; however, this doesn't seem to be true for
// urxvt 9.22 packaged by Debian; those keys simply send the same sequence
// as Ctrl-modified keys (\eO[abcd]).
'A': ui.K(ui.Up), 'B': ui.K(ui.Down), 'C': ui.K(ui.Right), 'D': ui.K(ui.Left),
'H': ui.K(ui.Home), 'F': ui.K(ui.End), 'M': ui.K(ui.Insert),
// urxvt
'a': ui.K(ui.Up, ui.Ctrl), 'b': ui.K(ui.Down, ui.Ctrl),
'c': ui.K(ui.Right, ui.Ctrl), 'd': ui.K(ui.Left, ui.Ctrl),
// xterm, urxvt, tmux
'P': ui.K(ui.F1), 'Q': ui.K(ui.F2), 'R': ui.K(ui.F3), 'S': ui.K(ui.F4),
}
// Tables for CSI-style key sequences. A CSI sequence is \e[ followed by zero or
// more numerical arguments (separated by semicolons), ending in a non-numeric,
// non-semicolon rune. They are used for many purposes, and CSI-style key
// sequences are a subset of them.
//
// There are several variants of CSI-style key sequences; see comments above the
// respective tables. In all variants, modifier keys are encoded in numerical
// arguments; see xtermModify. Note that although the set of possible sequences
// make it possible to express a very complete set of key combinations, they are
// not always sent by terminals. For instance, many (if not most) terminals will
// send the same sequence for Up when Shift-Up is pressed, even if Shift-Up is
// expressible using the escape sequences described below.
// CSI-style key sequences identified by the last rune. For instance, \e[A is
// Up. When modified, two numerical arguments are added, the first always being
// 1 and the second identifying the modifier. For instance, \e[1;5A is Ctrl-Up.
var csiSeqByLast = map[rune]ui.Key{
// xterm, urxvt, tmux
'A': ui.K(ui.Up), 'B': ui.K(ui.Down), 'C': ui.K(ui.Right), 'D': ui.K(ui.Left),
// urxvt
'a': ui.K(ui.Up, ui.Shift), 'b': ui.K(ui.Down, ui.Shift),
'c': ui.K(ui.Right, ui.Shift), 'd': ui.K(ui.Left, ui.Shift),
// xterm (Terminal.app only sends those in alternate screen)
'H': ui.K(ui.Home), 'F': ui.K(ui.End),
// xterm, urxvt, tmux
'Z': ui.K(ui.Tab, ui.Shift),
}
// CSI-style key sequences ending with '~' with by one or two numerical
// arguments. The first argument identifies the key, and the optional second
// argument identifies the modifier. For instance, \e[3~ is Delete, and \e[3;5~
// is Ctrl-Delete.
//
// An alternative encoding of the modifier key, only known to be used by urxvt
// (or for that matter, likely also rxvt) is to change the last rune: '$' for
// Shift, '^' for Ctrl, and '@' for Ctrl+Shift. The numeric argument is kept
// unchanged. For instance, \e[3^ is Ctrl-Delete.
var csiSeqTilde = map[int]rune{
// tmux (NOTE: urxvt uses the pair for Find/Select)
1: ui.Home, 4: ui.End,
// xterm (Terminal.app sends ^M for Fn+Enter), urxvt, tmux
2: ui.Insert,
// xterm, urxvt, tmux
3: ui.Delete,
// xterm (Terminal.app only sends those in alternate screen), urxvt, tmux
// NOTE: called Prior/Next in urxvt manpage
5: ui.PageUp, 6: ui.PageDown,
// urxvt
7: ui.Home, 8: ui.End,
// urxvt
11: ui.F1, 12: ui.F2, 13: ui.F3, 14: ui.F4,
// xterm, urxvt, tmux
// NOTE: 16 and 22 are unused
15: ui.F5, 17: ui.F6, 18: ui.F7, 19: ui.F8,
20: ui.F9, 21: ui.F10, 23: ui.F11, 24: ui.F12,
}
// CSI-style key sequences ending with '~', with the first argument always 27,
// the second argument identifying the modifier, and the third argument
// identifying the key. For instance, \e[27;5;9~ is Ctrl-Tab.
//
// NOTE(xiaq): The list is taken blindly from xterm-keys.c in the tmux source
// tree. I do not have a keyboard-terminal combination that generate such
// sequences, but assumably they are generated by some terminals for numpad
// inputs.
var csiSeqTilde27 = map[int]rune{
9: '\t', 13: '\r',
33: '!', 35: '#', 39: '\'', 40: '(', 41: ')', 43: '+', 44: ',', 45: '-',
46: '.',
48: '0', 49: '1', 50: '2', 51: '3', 52: '4', 53: '5', 54: '6', 55: '7',
56: '8', 57: '9',
58: ':', 59: ';', 60: '<', 61: '=', 62: '>', 63: ';',
}
// parseCSI parses a CSI-style key sequence. See comments above for all the 3
// variants this function handles.
func parseCSI(nums []int, last rune, seq string) ui.Key {
if k, ok := csiSeqByLast[last]; ok {
if len(nums) == 0 {
// Unmodified: \e[A (Up)
return k
} else if len(nums) == 2 && nums[0] == 1 {
// Modified: \e[1;5A (Ctrl-Up)
return xtermModify(k, nums[1], seq)
} else {
return ui.Key{}
}
}
switch last {
case '~':
if len(nums) == 1 || len(nums) == 2 {
if r, ok := csiSeqTilde[nums[0]]; ok {
k := ui.K(r)
if len(nums) == 1 {
// Unmodified: \e[5~ (e.g. PageUp)
return k
}
// Modified: \e[5;5~ (e.g. Ctrl-PageUp)
return xtermModify(k, nums[1], seq)
}
} else if len(nums) == 3 && nums[0] == 27 {
if r, ok := csiSeqTilde27[nums[2]]; ok {
k := ui.K(r)
return xtermModify(k, nums[1], seq)
}
}
case '$', '^', '@':
// Modified by urxvt; see comment above csiSeqTilde.
if len(nums) == 1 {
if r, ok := csiSeqTilde[nums[0]]; ok {
var mod ui.Mod
switch last {
case '$':
mod = ui.Shift
case '^':
mod = ui.Ctrl
case '@':
mod = ui.Shift | ui.Ctrl
}
return ui.K(r, mod)
}
}
}
return ui.Key{}
}
func xtermModify(k ui.Key, mod int, seq string) ui.Key {
if mod < 0 || mod > 16 {
// Out of range
return ui.Key{}
}
if mod == 0 {
return k
}
modFlags := mod - 1
if modFlags&0x1 != 0 {
k.Mod |= ui.Shift
}
if modFlags&0x2 != 0 {
k.Mod |= ui.Alt
}
if modFlags&0x4 != 0 {
k.Mod |= ui.Ctrl
}
if modFlags&0x8 != 0 {
// This should be Meta, but we currently conflate Meta and Alt.
k.Mod |= ui.Alt
}
return k
}
func mouseModify(n int) ui.Mod {
var mod ui.Mod
if n&4 != 0 {
mod |= ui.Shift
}
if n&8 != 0 {
mod |= ui.Alt
}
if n&16 != 0 {
mod |= ui.Ctrl
}
return mod
}
elvish-0.20.1/pkg/cli/term/reader_unix_test.go 0000664 0000000 0000000 00000013560 14570151573 0021273 0 ustar 00root root 0000000 0000000 //go:build unix
package term
import (
"os"
"strings"
"testing"
"src.elv.sh/pkg/must"
"src.elv.sh/pkg/ui"
)
var readEventTests = []struct {
input string
want Event
}{
// Simple graphical key.
{"x", K('x')},
{"X", K('X')},
{" ", K(' ')},
// Ctrl key.
{"\001", K('A', ui.Ctrl)},
{"\033", K('[', ui.Ctrl)},
// Special Ctrl keys that do not obey the usual 0x40 rule.
{"\000", K('`', ui.Ctrl)},
{"\x1e", K('6', ui.Ctrl)},
{"\x1f", K('/', ui.Ctrl)},
// Ambiguous Ctrl keys; the reader uses the non-Ctrl form as canonical.
{"\n", K('\n')},
{"\t", K('\t')},
{"\x7f", K('\x7f')}, // backspace
// Alt plus simple graphical key.
{"\033a", K('a', ui.Alt)},
{"\033[", K('[', ui.Alt)},
// G3-style key.
{"\033OA", K(ui.Up)},
{"\033OH", K(ui.Home)},
// G3-style key with leading Escape.
{"\033\033OA", K(ui.Up, ui.Alt)},
{"\033\033OH", K(ui.Home, ui.Alt)},
// Alt-O. This is handled as a special case because it looks like a G3-style
// key.
{"\033O", K('O', ui.Alt)},
// CSI-sequence key identified by the ending rune.
{"\033[A", K(ui.Up)},
{"\033[H", K(ui.Home)},
// Modifiers.
{"\033[1;0A", K(ui.Up)},
{"\033[1;1A", K(ui.Up)},
{"\033[1;2A", K(ui.Up, ui.Shift)},
{"\033[1;3A", K(ui.Up, ui.Alt)},
{"\033[1;4A", K(ui.Up, ui.Shift, ui.Alt)},
{"\033[1;5A", K(ui.Up, ui.Ctrl)},
{"\033[1;6A", K(ui.Up, ui.Shift, ui.Ctrl)},
{"\033[1;7A", K(ui.Up, ui.Alt, ui.Ctrl)},
{"\033[1;8A", K(ui.Up, ui.Shift, ui.Alt, ui.Ctrl)},
// The modifiers below should be for Meta, but we conflate Alt and Meta.
{"\033[1;9A", K(ui.Up, ui.Alt)},
{"\033[1;10A", K(ui.Up, ui.Shift, ui.Alt)},
{"\033[1;11A", K(ui.Up, ui.Alt)},
{"\033[1;12A", K(ui.Up, ui.Shift, ui.Alt)},
{"\033[1;13A", K(ui.Up, ui.Alt, ui.Ctrl)},
{"\033[1;14A", K(ui.Up, ui.Shift, ui.Alt, ui.Ctrl)},
{"\033[1;15A", K(ui.Up, ui.Alt, ui.Ctrl)},
{"\033[1;16A", K(ui.Up, ui.Shift, ui.Alt, ui.Ctrl)},
// CSI-sequence key with one argument, ending in '~'.
{"\033[1~", K(ui.Home)},
{"\033[11~", K(ui.F1)},
// Modified.
{"\033[1;2~", K(ui.Home, ui.Shift)},
// Urxvt-flavor modifier, shifting the '~' to reflect the modifier
{"\033[1$", K(ui.Home, ui.Shift)},
{"\033[1^", K(ui.Home, ui.Ctrl)},
{"\033[1@", K(ui.Home, ui.Shift, ui.Ctrl)},
// With a leading Escape.
{"\033\033[1~", K(ui.Home, ui.Alt)},
// CSI-sequence key with three arguments and ending in '~'. The first
// argument is always 27, the second identifies the modifier and the last
// identifies the key.
{"\033[27;4;63~", K(';', ui.Shift, ui.Alt)},
// Cursor Position Report.
{"\033[3;4R", CursorPosition{3, 4}},
// Paste setting.
{"\033[200~", PasteSetting(true)},
{"\033[201~", PasteSetting(false)},
// Mouse event.
{"\033[M\x00\x23\x24", MouseEvent{Pos{4, 3}, true, 0, 0}},
// Other buttons.
{"\033[M\x01\x23\x24", MouseEvent{Pos{4, 3}, true, 1, 0}},
// Button up.
{"\033[M\x03\x23\x24", MouseEvent{Pos{4, 3}, false, -1, 0}},
// Modified.
{"\033[M\x04\x23\x24", MouseEvent{Pos{4, 3}, true, 0, ui.Shift}},
{"\033[M\x08\x23\x24", MouseEvent{Pos{4, 3}, true, 0, ui.Alt}},
{"\033[M\x10\x23\x24", MouseEvent{Pos{4, 3}, true, 0, ui.Ctrl}},
{"\033[M\x14\x23\x24", MouseEvent{Pos{4, 3}, true, 0, ui.Shift | ui.Ctrl}},
// SGR-style mouse event.
{"\033[<0;3;4M", MouseEvent{Pos{4, 3}, true, 0, 0}},
// Other buttons.
{"\033[<1;3;4M", MouseEvent{Pos{4, 3}, true, 1, 0}},
// Button up.
{"\033[<0;3;4m", MouseEvent{Pos{4, 3}, false, 0, 0}},
// Modified.
{"\033[<4;3;4M", MouseEvent{Pos{4, 3}, true, 0, ui.Shift}},
{"\033[<16;3;4M", MouseEvent{Pos{4, 3}, true, 0, ui.Ctrl}},
}
func TestReader_ReadEvent(t *testing.T) {
r, w := setupReader(t)
for _, test := range readEventTests {
t.Run(test.input, func(t *testing.T) {
w.WriteString(test.input)
ev, err := r.ReadEvent()
if ev != test.want {
t.Errorf("got event %v, want %v", ev, test.want)
}
if err != nil {
t.Errorf("got err %v, want %v", err, nil)
}
})
}
}
var readEventBadSeqTests = []struct {
input string
wantErrMsg string
}{
// mouse event should have exactly 3 bytes after \033[M
{"\033[M", "incomplete mouse event"},
{"\033[M1", "incomplete mouse event"},
{"\033[M12", "incomplete mouse event"},
// CSI needs to be terminated by something that is not a parameter
{"\033[1", "incomplete CSI"},
{"\033[;", "incomplete CSI"},
{"\033[1;", "incomplete CSI"},
// CPR should have exactly 2 parameters
{"\033[1R", "bad CPR"},
{"\033[1;2;3R", "bad CPR"},
// SGR mouse event should have exactly 3 parameters
{"\033[<1;2m", "bad SGR mouse event"},
// csiSeqByLast should have 0 or 2 parameters
{"\033[1;2;3A", "bad CSI"},
// csiSeqByLast with 2 parameters should have first parameter = 1
{"\033[2;1A", "bad CSI"},
// xterm-style modifier should be 0 to 16
{"\033[1;17A", "bad CSI"},
// unknown CSI terminator
{"\033[x", "bad CSI"},
// G3 allows a small list of allowed bytes after \033O
{"\033Ox", "bad G3"},
}
func TestReader_ReadEvent_BadSeq(t *testing.T) {
r, w := setupReader(t)
for _, test := range readEventBadSeqTests {
t.Run(test.input, func(t *testing.T) {
w.WriteString(test.input)
ev, err := r.ReadEvent()
if err == nil {
t.Fatalf("got nil err with event %v, want non-nil error", ev)
}
errMsg := err.Error()
if !strings.HasPrefix(errMsg, test.wantErrMsg) {
t.Errorf("got err with message %v, want message starting with %v",
errMsg, test.wantErrMsg)
}
})
}
}
func TestReader_ReadRawEvent(t *testing.T) {
rd, w := setupReader(t)
for _, test := range readEventTests {
input := test.input
t.Run(input, func(t *testing.T) {
w.WriteString(input)
for _, r := range input {
ev, err := rd.ReadRawEvent()
if err != nil {
t.Errorf("got error %v, want nil", err)
}
if ev != K(r) {
t.Errorf("got event %v, want %v", ev, K(r))
}
}
})
}
}
func setupReader(t *testing.T) (Reader, *os.File) {
pr, pw := must.Pipe()
r := NewReader(pr)
t.Cleanup(func() {
r.Close()
pr.Close()
pw.Close()
})
return r, pw
}
elvish-0.20.1/pkg/cli/term/reader_windows.go 0000664 0000000 0000000 00000014150 14570151573 0020737 0 ustar 00root root 0000000 0000000 package term
import (
"fmt"
"io"
"log"
"os"
"sync"
"unicode/utf16"
"golang.org/x/sys/windows"
"src.elv.sh/pkg/sys/ewindows"
"src.elv.sh/pkg/ui"
)
type reader struct {
console windows.Handle
stopEvent windows.Handle
// A mutex that is held during ReadEvent.
mutex sync.Mutex
}
// Creates a new Reader instance.
func newReader(file *os.File) Reader {
console, err := windows.GetStdHandle(windows.STD_INPUT_HANDLE)
if err != nil {
panic(fmt.Errorf("GetStdHandle(STD_INPUT_HANDLE): %v", err))
}
stopEvent, err := windows.CreateEvent(nil, 0, 0, nil)
if err != nil {
panic(fmt.Errorf("CreateEvent: %v", err))
}
return &reader{console: console, stopEvent: stopEvent}
}
func (r *reader) ReadEvent() (Event, error) {
r.mutex.Lock()
defer r.mutex.Unlock()
handles := []windows.Handle{r.console, r.stopEvent}
var leadingSurrogate *surrogateKeyEvent
for {
triggered, _, err := ewindows.WaitForMultipleObjects(handles, false, ewindows.INFINITE)
if err != nil {
return nil, err
}
if triggered == 1 {
return nil, ErrStopped
}
var buf [1]ewindows.InputRecord
nr, err := ewindows.ReadConsoleInput(r.console, buf[:])
if nr == 0 {
return nil, io.ErrNoProgress
}
if err != nil {
return nil, err
}
event := convertEvent(buf[0].GetEvent())
if surrogate, ok := event.(surrogateKeyEvent); ok {
if leadingSurrogate == nil {
leadingSurrogate = &surrogate
// Keep reading the trailing surrogate.
continue
} else {
r := utf16.DecodeRune(leadingSurrogate.r, surrogate.r)
return KeyEvent{Rune: r}, nil
}
}
if event != nil {
return event, nil
}
// Got an event that should be ignored; keep going.
}
}
func (r *reader) ReadRawEvent() (Event, error) {
return r.ReadEvent()
}
func (r *reader) Close() {
err := windows.SetEvent(r.stopEvent)
if err != nil {
log.Println("SetEvent:", err)
}
r.mutex.Lock()
//lint:ignore SA2001 We only lock the mutex to make sure that ReadEvent has
//exited, so we unlock it immediately.
r.mutex.Unlock()
err = windows.CloseHandle(r.stopEvent)
if err != nil {
log.Println("Closing stopEvent handle for reader:", err)
}
}
// A subset of virtual key codes listed in
// https://msdn.microsoft.com/en-us/library/windows/desktop/dd375731(v=vs.85).aspx
var keyCodeToRune = map[uint16]rune{
0x08: ui.Backspace, 0x09: ui.Tab,
0x0d: ui.Enter,
0x1b: '\x1b',
0x20: ' ',
0x23: ui.End, 0x24: ui.Home,
0x25: ui.Left, 0x26: ui.Up, 0x27: ui.Right, 0x28: ui.Down,
0x2d: ui.Insert, 0x2e: ui.Delete,
/* 0x30 - 0x39: digits, same with ASCII */
/* 0x41 - 0x5a: letters, same with ASCII */
/* 0x60 - 0x6f: numpads; currently ignored */
0x70: ui.F1, 0x71: ui.F2, 0x72: ui.F3, 0x73: ui.F4, 0x74: ui.F5, 0x75: ui.F6,
0x76: ui.F7, 0x77: ui.F8, 0x78: ui.F9, 0x79: ui.F10, 0x7a: ui.F11, 0x7b: ui.F12,
/* 0x7c - 0x87: F13 - F24; currently ignored */
0xba: ';', 0xbb: '=', 0xbc: ',', 0xbd: '-', 0xbe: '.', 0xbf: '/', 0xc0: '`',
0xdb: '[', 0xdc: '\\', 0xdd: ']', 0xde: '\'',
}
// A subset of constants listed in
// https://docs.microsoft.com/en-us/windows/console/key-event-record-str
const (
leftAlt = 0x02
leftCtrl = 0x08
rightAlt = 0x01
rightCtrl = 0x04
shift = 0x10
)
type surrogateKeyEvent struct{ r rune }
func (surrogateKeyEvent) isEvent() {}
// Converts the native ewindows.InputEvent type to a suitable Event type. It returns
// nil if the event should be ignored.
func convertEvent(event ewindows.InputEvent) Event {
switch event := event.(type) {
case *ewindows.KeyEvent:
if event.BKeyDown == 0 {
// Ignore keyup events.
return nil
}
r := rune(event.UChar[0]) + rune(event.UChar[1])<<8
filteredMod := event.DwControlKeyState & (leftAlt | leftCtrl | rightAlt | rightCtrl | shift)
if r >= 0x20 && r != 0x7f {
// This key inputs a character. The flags present in
// DwControlKeyState might indicate modifier keys that are needed to
// input this character (e.g. the Shift key when inputting 'A'), or
// modifier keys that are pressed in addition (e.g. the Alt key when
// pressing Alt-A). There doesn't seem to be an easy way to tell
// which is the case, so we rely on heuristics derived from
// real-world observations.
if filteredMod == 0 {
if utf16.IsSurrogate(r) {
return surrogateKeyEvent{r}
} else {
return KeyEvent(ui.Key{Rune: r})
}
} else if filteredMod == shift {
// A lone Shift seems to be always part of the character.
return KeyEvent(ui.Key{Rune: r})
} else if filteredMod == leftCtrl|rightAlt || filteredMod == leftCtrl|rightAlt|shift {
// The combination leftCtrl|rightAlt is used to represent AltGr.
// Furthermore, when the actual left Ctrl and right Alt are used
// together, the UChar field seems to be always 0; so if we are
// here, we can actually be sure that it's AltGr.
//
// Some characters require AltGr+Shift to input, such as the
// upper-case sharp S on a German keyboard.
return KeyEvent(ui.Key{Rune: r})
}
}
mod := convertMod(filteredMod)
if mod == 0 && event.WVirtualKeyCode == 0x1b {
// Special case: Normalize 0x1b to Ctrl-[.
//
// TODO(xiaq): This is Unix-centric. Maybe the normalized form
// should be Escape.
return KeyEvent(ui.Key{Rune: '[', Mod: ui.Ctrl})
}
r = convertRune(event.WVirtualKeyCode, mod)
if r == 0 {
return nil
}
return KeyEvent(ui.Key{Rune: r, Mod: mod})
default:
// Other events are ignored.
return nil
}
}
func convertRune(keyCode uint16, mod ui.Mod) rune {
r, ok := keyCodeToRune[keyCode]
if ok {
return r
}
if '0' <= keyCode && keyCode <= '9' {
return rune(keyCode)
}
if 'A' <= keyCode && keyCode <= 'Z' {
// If Ctrl is involved, emulate Unix's convention and use upper case;
// otherwise use lower case.
//
// TODO(xiaq): This is quite Unix-centric. Maybe we should make the
// base rune case-insensitive when there are modifiers involved.
if mod&ui.Ctrl != 0 {
return rune(keyCode)
}
return rune(keyCode - 'A' + 'a')
}
return 0
}
func convertMod(state uint32) ui.Mod {
mod := ui.Mod(0)
if state&(leftAlt|rightAlt) != 0 {
mod |= ui.Alt
}
if state&(leftCtrl|rightCtrl) != 0 {
mod |= ui.Ctrl
}
if state&shift != 0 {
mod |= ui.Shift
}
return mod
}
elvish-0.20.1/pkg/cli/term/reader_windows_test.go 0000664 0000000 0000000 00000003212 14570151573 0021773 0 ustar 00root root 0000000 0000000 package term
import (
"testing"
"unicode/utf16"
"src.elv.sh/pkg/sys/ewindows"
"src.elv.sh/pkg/tt"
"src.elv.sh/pkg/ui"
)
func TestConvertEvent(t *testing.T) {
r1, r2 := utf16.EncodeRune('😀')
tt.Test(t, convertEvent,
// Only convert KeyEvent
Args(&ewindows.MouseEvent{}).Rets(nil),
// Only convert KeyDown events
Args(&ewindows.KeyEvent{BKeyDown: 0}).Rets(nil),
Args(charKeyEvent('a', 0)).Rets(K('a')),
Args(charKeyEvent('A', shift)).Rets(K('A')),
Args(charKeyEvent('µ', leftCtrl|rightAlt)).Rets(K('µ')),
Args(charKeyEvent('ẞ', leftCtrl|rightAlt|shift)).Rets(K('ẞ')),
Args(charKeyEvent(uint16(r1), 0)).Rets(surrogateKeyEvent{r1}),
Args(charKeyEvent(uint16(r2), 0)).Rets(surrogateKeyEvent{r2}),
Args(funcKeyEvent(0x1b, 0)).Rets(K('[', ui.Ctrl)),
// Functional key with modifiers
Args(funcKeyEvent(0x08, 0)).Rets(K(ui.Backspace)),
Args(funcKeyEvent(0x08, leftCtrl)).Rets(K(ui.Backspace, ui.Ctrl)),
Args(funcKeyEvent(0x08, leftCtrl|leftAlt|shift)).Rets(K(ui.Backspace, ui.Ctrl, ui.Alt, ui.Shift)),
// Functional keys with an alphanumeric base
Args(funcKeyEvent('2', leftCtrl)).Rets(K('2', ui.Ctrl)),
Args(funcKeyEvent('A', leftCtrl)).Rets(K('A', ui.Ctrl)),
Args(funcKeyEvent('A', leftAlt)).Rets(K('a', ui.Alt)),
// Unrecognized functional key
Args(funcKeyEvent(0, 0)).Rets(nil),
)
}
func charKeyEvent(r uint16, mod uint32) *ewindows.KeyEvent {
return &ewindows.KeyEvent{
BKeyDown: 1, DwControlKeyState: mod, UChar: [2]byte{byte(r), byte(r >> 8)}}
}
func funcKeyEvent(code uint16, mod uint32) *ewindows.KeyEvent {
return &ewindows.KeyEvent{
BKeyDown: 1, DwControlKeyState: mod, WVirtualKeyCode: code}
}
elvish-0.20.1/pkg/cli/term/setup.go 0000664 0000000 0000000 00000005603 14570151573 0017066 0 ustar 00root root 0000000 0000000 package term
import (
"fmt"
"os"
"src.elv.sh/pkg/sys"
"src.elv.sh/pkg/wcwidth"
)
// Setup sets up the terminal so that it is suitable for the Reader and
// Writer to use. It returns a function that can be used to restore the
// original terminal config.
func Setup(in, out *os.File) (func() error, error) {
return setup(in, out)
}
// SetupForEval sets up the terminal for evaluating Elvish code. It returns a
// function to call after the evaluation finishes.
func SetupForEval(in, out *os.File) func() {
return setupForEval(in, out)
}
const (
lackEOLRune = '\u23ce'
lackEOL = "\033[7m" + string(lackEOLRune) + "\033[m"
enableSGRMouse = false
)
// setupVT performs setup for VT-like terminals.
func setupVT(out *os.File) error {
_, width := sys.WinSize(out)
s := ""
/*
Write a lackEOLRune if the cursor is not in the leftmost column. This is
done as follows:
1. Turn on autowrap;
2. Write lackEOL along with enough padding, so that the total width is
equal to the width of the screen.
If the cursor was in the first column, we are still in the same line,
just off the line boundary. Otherwise, we are now in the next line.
3. Rewind to the first column, write one space and rewind again. If the
cursor was in the first column to start with, we have just erased the
LackEOL character. Otherwise, we are now in the next line and this is
a no-op. The LackEOL character remains.
*/
s += fmt.Sprintf("\033[?7h%s%*s\r \r", lackEOL, width-wcwidth.OfRune(lackEOLRune), "")
/*
Turn off autowrap.
The terminals sometimes has different opinions about how wide some
characters are (notably emojis and some dingbats) with elvish. When that
happens, elvish becomes wrong about where the cursor is when it writes
its output, and the effect can be disastrous.
If we turn off autowrap, the terminal won't insert any newlines behind
the scene, so elvish is always right about which line the cursor is.
With a bit more caution, this can restrict the consequence of the
mismatch within one line.
*/
s += "\033[?7l"
// Turn on SGR-style mouse tracking.
if enableSGRMouse {
s += "\033[?1000;1006h"
}
// Enable bracketed paste.
s += "\033[?2004h"
_, err := out.WriteString(s)
return err
}
// restoreVT performs restore for VT-like terminals.
func restoreVT(out *os.File) error {
s := ""
// Turn on autowrap.
s += "\033[?7h"
// Turn off mouse tracking.
if enableSGRMouse {
s += "\033[?1000;1006l"
}
// Disable bracketed paste.
s += "\033[?2004l"
// Move the cursor to the first row, even if we haven't written anything
// visible. This is because the terminal driver might not be smart enough to
// recognize some escape sequences as invisible and wrongly assume that we
// are not in the first column, which can mess up with tabs. See
// https://src.elv.sh/pkg/issues/629 for an example.
s += "\r"
_, err := out.WriteString(s)
return err
}
elvish-0.20.1/pkg/cli/term/setup_unix.go 0000664 0000000 0000000 00000003036 14570151573 0020127 0 ustar 00root root 0000000 0000000 //go:build unix
package term
import (
"fmt"
"os"
"golang.org/x/sys/unix"
"src.elv.sh/pkg/errutil"
"src.elv.sh/pkg/sys/eunix"
)
func setup(in, out *os.File) (func() error, error) {
// On Unix, use input file for changing termios. All fds pointing to the
// same terminal are equivalent.
fd := int(in.Fd())
term, err := eunix.TermiosForFd(fd)
if err != nil {
return nil, fmt.Errorf("can't get terminal attribute: %s", err)
}
savedTermios := term.Copy()
term.SetICanon(false)
term.SetIExten(false)
term.SetEcho(false)
term.SetVMin(1)
term.SetVTime(0)
// Enforcing crnl translation on readline. Assuming user won't set
// inlcr or -onlcr, otherwise we have to hardcode all of them here.
term.SetICRNL(true)
err = term.ApplyToFd(fd)
if err != nil {
return nil, fmt.Errorf("can't set up terminal attribute: %s", err)
}
var errSetupVT error
err = setupVT(out)
if err != nil {
errSetupVT = fmt.Errorf("can't setup VT: %s", err)
}
restore := func() error {
return errutil.Multi(savedTermios.ApplyToFd(fd), restoreVT(out))
}
return restore, errSetupVT
}
func setupForEval(in, out *os.File) func() {
// There is nothing to set up on Unix, but we try to sanitize the terminal
// when evaluation finishes.
return func() { sanitize(in, out) }
}
func sanitize(in, out *os.File) {
// Some programs use non-blocking IO but do not correctly clear the
// non-blocking flags after exiting, so we always clear the flag. See #822
// for an example.
unix.SetNonblock(int(in.Fd()), false)
unix.SetNonblock(int(out.Fd()), false)
}
elvish-0.20.1/pkg/cli/term/setup_unix_test.go 0000664 0000000 0000000 00000000755 14570151573 0021173 0 ustar 00root root 0000000 0000000 //go:build unix
package term
import (
"testing"
"github.com/creack/pty"
)
func TestSetupTerminal(t *testing.T) {
pty, tty, err := pty.Open()
if err != nil {
t.Skip("cannot open pty for testing setupTerminal")
}
defer pty.Close()
defer tty.Close()
_, err = setup(tty, tty)
if err != nil {
t.Errorf("setupTerminal returns an error")
}
// TODO(xiaq): Test whether the interesting flags in the termios were indeed
// set.
// termios, err := sys.TermiosForFd(int(tty.Fd()))
}
elvish-0.20.1/pkg/cli/term/setup_windows.go 0000664 0000000 0000000 00000002413 14570151573 0020634 0 ustar 00root root 0000000 0000000 package term
import (
"os"
"golang.org/x/sys/windows"
"src.elv.sh/pkg/errutil"
)
const (
inMode = windows.ENABLE_WINDOW_INPUT |
windows.ENABLE_MOUSE_INPUT | windows.ENABLE_PROCESSED_INPUT
outMode = windows.ENABLE_PROCESSED_OUTPUT |
windows.ENABLE_WRAP_AT_EOL_OUTPUT |
windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING
)
func setup(in, out *os.File) (func() error, error) {
hIn := windows.Handle(in.Fd())
hOut := windows.Handle(out.Fd())
var oldInMode, oldOutMode uint32
err := windows.GetConsoleMode(hIn, &oldInMode)
if err != nil {
return nil, err
}
err = windows.GetConsoleMode(hOut, &oldOutMode)
if err != nil {
return nil, err
}
errSetIn := windows.SetConsoleMode(hIn, inMode)
errSetOut := windows.SetConsoleMode(hOut, outMode)
errVT := setupVT(out)
return func() error {
return errutil.Multi(
restoreVT(out),
windows.SetConsoleMode(hOut, oldOutMode),
windows.SetConsoleMode(hIn, oldInMode))
}, errutil.Multi(errSetIn, errSetOut, errVT)
}
const outFlagForEval = windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING
func setupForEval(_, out *os.File) func() {
h := windows.Handle(out.Fd())
var oldOutMode uint32
err := windows.GetConsoleMode(h, &oldOutMode)
if err == nil {
windows.SetConsoleMode(h, oldOutMode|outFlagForEval)
}
return func() {}
}
elvish-0.20.1/pkg/cli/term/setup_windows_test.go 0000664 0000000 0000000 00000003243 14570151573 0021675 0 ustar 00root root 0000000 0000000 package term
import (
"os"
"testing"
"golang.org/x/sys/windows"
)
func TestSetupForEval(t *testing.T) {
// open CONOUT$ manually because os.Stdout is redirected during testing
out := openFile(t, "CONOUT$", os.O_RDWR, 0)
defer out.Close()
// Start with ENABLE_VIRTUAL_TERMINAL_PROCESSING
initialOutMode := getConsoleMode(t, out) | windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING
setConsoleMode(t, out, initialOutMode)
// Clear ENABLE_VIRTUAL_TERMINAL_PROCESSING
modifiedOutMode := initialOutMode &^ windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING
setConsoleMode(t, out, modifiedOutMode)
// Check that SetupForEval sets ENABLE_VIRTUAL_TERMINAL_PROCESSING without
// changing other bits
restore := setupForEval(nil, out)
if got := getConsoleMode(t, out); got != initialOutMode {
t.Errorf("got console mode %v, want %v", got, initialOutMode)
}
// Check that restore is a no-op
setConsoleMode(t, out, modifiedOutMode)
restore()
if got := getConsoleMode(t, out); got != modifiedOutMode {
t.Errorf("got console mode %v, want %v", got, modifiedOutMode)
}
}
func openFile(t *testing.T, name string, flag int, perm os.FileMode) *os.File {
t.Helper()
out, err := os.OpenFile(name, flag, perm)
if err != nil {
t.Fatalf("open %s: %v", name, err)
}
return out
}
func setConsoleMode(t *testing.T, file *os.File, mode uint32) {
t.Helper()
err := windows.SetConsoleMode(windows.Handle(file.Fd()), mode)
if err != nil {
t.Fatal("SetConsoleMode:", err)
}
}
func getConsoleMode(t *testing.T, file *os.File) uint32 {
t.Helper()
var mode uint32
err := windows.GetConsoleMode(windows.Handle(file.Fd()), &mode)
if err != nil {
t.Fatal("GetConsoleMode:", err)
}
return mode
}
elvish-0.20.1/pkg/cli/term/term.go 0000664 0000000 0000000 00000000240 14570151573 0016665 0 ustar 00root root 0000000 0000000 // Package term provides functionality for working with terminals.
package term
import "src.elv.sh/pkg/logutil"
var logger = logutil.GetLogger("[cli/term] ")
elvish-0.20.1/pkg/cli/term/writer.go 0000664 0000000 0000000 00000012023 14570151573 0017234 0 ustar 00root root 0000000 0000000 package term
import (
"bytes"
"fmt"
"io"
)
var logWriterDetail = false
// Writer represents the output to a terminal.
type Writer interface {
// Buffer returns the current buffer.
Buffer() *Buffer
// ResetBuffer resets the current buffer.
ResetBuffer()
// UpdateBuffer updates the terminal display to reflect current buffer.
UpdateBuffer(bufNoti, buf *Buffer, fullRefresh bool) error
// ClearScreen clears the terminal screen and places the cursor at the top
// left corner.
ClearScreen()
// ShowCursor shows the cursor.
ShowCursor()
// HideCursor hides the cursor.
HideCursor()
}
// writer renders the editor UI.
type writer struct {
file io.Writer
curBuf *Buffer
}
// NewWriter returns a Writer that writes VT100 sequences to the given io.Writer.
func NewWriter(f io.Writer) Writer {
return &writer{f, &Buffer{}}
}
func (w *writer) Buffer() *Buffer {
return w.curBuf
}
func (w *writer) ResetBuffer() {
w.curBuf = &Buffer{}
}
// deltaPos calculates the escape sequence needed to move the cursor from one
// position to another. It use relative movements to move to the destination
// line and absolute movement to move to the destination column.
func deltaPos(from, to Pos) []byte {
buf := new(bytes.Buffer)
if from.Line < to.Line {
// move down
fmt.Fprintf(buf, "\033[%dB", to.Line-from.Line)
} else if from.Line > to.Line {
// move up
fmt.Fprintf(buf, "\033[%dA", from.Line-to.Line)
}
fmt.Fprint(buf, "\r")
if to.Col > 0 {
fmt.Fprintf(buf, "\033[%dC", to.Col)
}
return buf.Bytes()
}
const (
hideCursor = "\033[?25l"
showCursor = "\033[?25h"
)
// UpdateBuffer updates the terminal display to reflect current buffer.
func (w *writer) UpdateBuffer(bufNoti, buf *Buffer, fullRefresh bool) error {
if buf.Width != w.curBuf.Width && w.curBuf.Lines != nil {
// Width change, force full refresh
w.curBuf.Lines = nil
fullRefresh = true
}
bytesBuf := new(bytes.Buffer)
bytesBuf.WriteString(hideCursor)
// Rewind cursor
if pLine := w.curBuf.Dot.Line; pLine > 0 {
fmt.Fprintf(bytesBuf, "\033[%dA", pLine)
}
bytesBuf.WriteString("\r")
if fullRefresh {
// Erase from here. We may be in the top right corner of the screen; if
// we simply do an erase here, tmux will save the current screen in the
// scrollback buffer (presumably as a heuristics to detect full-screen
// applications), but that is not something we want. So we write a space
// first, and then erase, before rewinding back.
//
// Source code for tmux behavior:
// https://github.com/tmux/tmux/blob/5f5f029e3b3a782dc616778739b2801b00b17c0e/screen-write.c#L1139
bytesBuf.WriteString(" \033[J\r")
}
// style of last written cell.
style := ""
switchStyle := func(newstyle string) {
if newstyle != style {
fmt.Fprintf(bytesBuf, "\033[0;%sm", newstyle)
style = newstyle
}
}
writeCells := func(cs []Cell) {
for _, c := range cs {
switchStyle(c.Style)
bytesBuf.WriteString(c.Text)
}
}
if bufNoti != nil {
if logWriterDetail {
logger.Printf("going to write %d lines of notifications", len(bufNoti.Lines))
}
// Write notifications
for _, line := range bufNoti.Lines {
writeCells(line)
switchStyle("")
bytesBuf.WriteString("\033[K\n")
}
// TODO(xiaq): This is hacky; try to improve it.
if len(w.curBuf.Lines) > 0 {
w.curBuf.Lines = w.curBuf.Lines[1:]
}
}
if logWriterDetail {
logger.Printf("going to write %d lines, oldBuf had %d", len(buf.Lines), len(w.curBuf.Lines))
}
for i, line := range buf.Lines {
if i > 0 {
bytesBuf.WriteString("\n")
}
var j int // First column where buf and oldBuf differ
// No need to update current line
if !fullRefresh && i < len(w.curBuf.Lines) {
var eq bool
if eq, j = CompareCells(line, w.curBuf.Lines[i]); eq {
continue
}
}
// Move to the first differing column if necessary.
firstCol := CellsWidth(line[:j])
if firstCol != 0 {
fmt.Fprintf(bytesBuf, "\033[%dC", firstCol)
}
// Erase the rest of the line if necessary.
if !fullRefresh && i < len(w.curBuf.Lines) && j < len(w.curBuf.Lines[i]) {
switchStyle("")
bytesBuf.WriteString("\033[K")
}
writeCells(line[j:])
}
if len(w.curBuf.Lines) > len(buf.Lines) && !fullRefresh {
// If the old buffer is higher, erase old content.
// Note that we cannot simply write \033[J, because if the cursor is
// just over the last column -- which is precisely the case if we have a
// rprompt, \033[J will also erase the last column.
switchStyle("")
bytesBuf.WriteString("\n\033[J\033[A")
}
switchStyle("")
cursor := buf.Cursor()
bytesBuf.Write(deltaPos(cursor, buf.Dot))
// Show cursor.
bytesBuf.WriteString(showCursor)
if logWriterDetail {
logger.Printf("going to write %q", bytesBuf.String())
}
_, err := w.file.Write(bytesBuf.Bytes())
if err != nil {
return err
}
w.curBuf = buf
return nil
}
func (w *writer) HideCursor() {
fmt.Fprint(w.file, hideCursor)
}
func (w *writer) ShowCursor() {
fmt.Fprint(w.file, showCursor)
}
func (w *writer) ClearScreen() {
fmt.Fprint(w.file,
"\033[H", // move cursor to the top left corner
"\033[2J", // clear entire buffer
)
}
elvish-0.20.1/pkg/cli/term/writer_test.go 0000664 0000000 0000000 00000000751 14570151573 0020300 0 ustar 00root root 0000000 0000000 package term
import (
"strings"
"testing"
)
func TestWriter(t *testing.T) {
sb := &strings.Builder{}
testOutput := func(want string) {
t.Helper()
if sb.String() != want {
t.Errorf("got %q, want %q", sb.String(), want)
}
sb.Reset()
}
w := NewWriter(sb)
w.UpdateBuffer(
NewBufferBuilder(10).Write("note 1").Buffer(),
NewBufferBuilder(10).Write("line 1").SetDotHere().Buffer(),
false)
testOutput(hideCursor + "\rnote 1\033[K\n" + "line 1\r\033[6C" + showCursor)
}
elvish-0.20.1/pkg/cli/tk/ 0000775 0000000 0000000 00000000000 14570151573 0015042 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/cli/tk/codearea.go 0000664 0000000 0000000 00000026374 14570151573 0017150 0 ustar 00root root 0000000 0000000 package tk
import (
"bytes"
"regexp"
"strings"
"sync"
"unicode"
"unicode/utf8"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/parse"
"src.elv.sh/pkg/ui"
)
// CodeArea is a Widget for displaying and editing code.
type CodeArea interface {
Widget
// CopyState returns a copy of the state.
CopyState() CodeAreaState
// MutateState calls the given the function while locking StateMutex.
MutateState(f func(*CodeAreaState))
// Submit triggers the OnSubmit callback.
Submit()
}
// CodeAreaSpec specifies the configuration and initial state for CodeArea.
type CodeAreaSpec struct {
// Key bindings.
Bindings Bindings
// A function that highlights the given code and returns any tips it has
// found, such as errors and autofixes. If this function is not given, the
// Widget does not highlight the code nor show any tips.
Highlighter func(code string) (ui.Text, []ui.Text)
// Prompt callback.
Prompt func() ui.Text
// Right-prompt callback.
RPrompt func() ui.Text
// A function that calls the callback with string pairs for abbreviations
// and their expansions. If no function is provided the Widget does not
// expand any abbreviations of the specified type.
SimpleAbbreviations func(f func(abbr, full string))
CommandAbbreviations func(f func(abbr, full string))
SmallWordAbbreviations func(f func(abbr, full string))
// A function that returns whether pasted texts (from bracketed pastes)
// should be quoted. If this function is not given, the Widget defaults to
// not quoting pasted texts.
QuotePaste func() bool
// A function that is called on the submit event.
OnSubmit func()
// State. When used in New, this field specifies the initial state.
State CodeAreaState
}
// CodeAreaState keeps the mutable state of the CodeArea widget.
type CodeAreaState struct {
Buffer CodeBuffer
Pending PendingCode
HideRPrompt bool
HideTips bool
}
// CodeBuffer represents the buffer of the CodeArea widget.
type CodeBuffer struct {
// Content of the buffer.
Content string
// Position of the dot (more commonly known as the cursor), as a byte index
// into Content.
Dot int
}
// PendingCode represents pending code, such as during completion.
type PendingCode struct {
// Beginning index of the text area that the pending code replaces, as a
// byte index into RawState.Code.
From int
// End index of the text area that the pending code replaces, as a byte
// index into RawState.Code.
To int
// The content of the pending code.
Content string
}
// ApplyPending applies pending code to the code buffer, and resets pending code.
func (s *CodeAreaState) ApplyPending() {
s.Buffer, _, _ = patchPending(s.Buffer, s.Pending)
s.Pending = PendingCode{}
}
func (c *CodeBuffer) InsertAtDot(text string) {
*c = CodeBuffer{
Content: c.Content[:c.Dot] + text + c.Content[c.Dot:],
Dot: c.Dot + len(text),
}
}
type codeArea struct {
// Mutex for synchronizing access to State.
StateMutex sync.RWMutex
// Configuration and state.
CodeAreaSpec
// Consecutively inserted text. Used for expanding abbreviations.
inserts string
// Value of State.CodeBuffer when handleKeyEvent was last called. Used for
// detecting whether insertion has been interrupted.
lastCodeBuffer CodeBuffer
// Whether the widget is in the middle of bracketed pasting.
pasting bool
// Buffer for keeping Pasted text during bracketed pasting.
pasteBuffer bytes.Buffer
}
// NewCodeArea creates a new CodeArea from the given spec.
func NewCodeArea(spec CodeAreaSpec) CodeArea {
if spec.Bindings == nil {
spec.Bindings = DummyBindings{}
}
if spec.Highlighter == nil {
spec.Highlighter = func(s string) (ui.Text, []ui.Text) { return ui.T(s), nil }
}
if spec.Prompt == nil {
spec.Prompt = func() ui.Text { return nil }
}
if spec.RPrompt == nil {
spec.RPrompt = func() ui.Text { return nil }
}
if spec.SimpleAbbreviations == nil {
spec.SimpleAbbreviations = func(func(a, f string)) {}
}
if spec.CommandAbbreviations == nil {
spec.CommandAbbreviations = func(func(a, f string)) {}
}
if spec.SmallWordAbbreviations == nil {
spec.SmallWordAbbreviations = func(func(a, f string)) {}
}
if spec.QuotePaste == nil {
spec.QuotePaste = func() bool { return false }
}
if spec.OnSubmit == nil {
spec.OnSubmit = func() {}
}
return &codeArea{CodeAreaSpec: spec}
}
// Submit emits a submit event with the current code content.
func (w *codeArea) Submit() {
w.OnSubmit()
}
// Render renders the code area, including the prompt and rprompt, highlighted
// code, the cursor, and compilation errors in the code content.
func (w *codeArea) Render(width, height int) *term.Buffer {
b := w.render(width)
truncateToHeight(b, height)
return b
}
func (w *codeArea) MaxHeight(width, height int) int {
return len(w.render(width).Lines)
}
func (w *codeArea) render(width int) *term.Buffer {
view := getView(w)
bb := term.NewBufferBuilder(width)
renderView(view, bb)
return bb.Buffer()
}
// Handle handles KeyEvent's of non-function keys, as well as PasteSetting
// events.
func (w *codeArea) Handle(event term.Event) bool {
switch event := event.(type) {
case term.PasteSetting:
return w.handlePasteSetting(bool(event))
case term.KeyEvent:
return w.handleKeyEvent(ui.Key(event))
}
return false
}
func (w *codeArea) MutateState(f func(*CodeAreaState)) {
w.StateMutex.Lock()
defer w.StateMutex.Unlock()
f(&w.State)
}
func (w *codeArea) CopyState() CodeAreaState {
w.StateMutex.RLock()
defer w.StateMutex.RUnlock()
return w.State
}
func (w *codeArea) resetInserts() {
w.inserts = ""
w.lastCodeBuffer = CodeBuffer{}
}
func (w *codeArea) handlePasteSetting(start bool) bool {
w.resetInserts()
if start {
w.pasting = true
} else {
text := w.pasteBuffer.String()
if w.QuotePaste() {
text = parse.Quote(text)
}
w.MutateState(func(s *CodeAreaState) { s.Buffer.InsertAtDot(text) })
w.pasting = false
w.pasteBuffer = bytes.Buffer{}
}
return true
}
// Tries to expand a simple abbreviation. This function assumes the state mutex is held.
func (w *codeArea) expandSimpleAbbr() {
var abbr, full string
// Find the longest matching abbreviation.
w.SimpleAbbreviations(func(a, f string) {
if strings.HasSuffix(w.inserts, a) && len(a) > len(abbr) {
abbr, full = a, f
}
})
if len(abbr) > 0 {
buf := &w.State.Buffer
*buf = CodeBuffer{
Content: buf.Content[:buf.Dot-len(abbr)] + full + buf.Content[buf.Dot:],
Dot: buf.Dot - len(abbr) + len(full),
}
w.resetInserts()
}
}
var commandRegex = regexp.MustCompile(`(?:^|[^^]\n|\||;|{\s|\()\s*([\p{L}\p{M}\p{N}!%+,\-./:@\\_<>*]+)(\s)$`)
// Tries to expand a command abbreviation. This function assumes the state mutex
// is held.
//
// We use a regex rather than parse.Parse() because dealing with the latter
// requires a lot of code. A simple regex is far simpler and good enough for
// this use case. The regex essentially matches commands at the start of the
// line (with potential leading whitespace) and similarly after the opening
// brace of a lambda or pipeline char.
//
// This only handles bareword commands.
func (w *codeArea) expandCommandAbbr() {
buf := &w.State.Buffer
if buf.Dot < len(buf.Content) {
// Command abbreviations are only expanded when inserting at the end of the buffer.
return
}
// See if there is something that looks like a bareword at the end of the buffer.
matches := commandRegex.FindStringSubmatch(buf.Content)
if len(matches) == 0 {
return
}
// Find an abbreviation matching the command.
command, whitespace := matches[1], matches[2]
var expansion string
w.CommandAbbreviations(func(a, e string) {
if a == command {
expansion = e
}
})
if expansion == "" {
return
}
// We found a matching abbreviation -- replace it with its expansion.
newContent := buf.Content[:buf.Dot-len(command)-1] + expansion + whitespace
*buf = CodeBuffer{
Content: newContent,
Dot: len(newContent),
}
w.resetInserts()
}
// Try to expand a small word abbreviation. This function assumes the state mutex is held.
func (w *codeArea) expandSmallWordAbbr(trigger rune, categorizer func(rune) int) {
buf := &w.State.Buffer
if buf.Dot < len(buf.Content) {
// Word abbreviations are only expanded when inserting at the end of the buffer.
return
}
triggerLen := len(string(trigger))
if triggerLen >= len(w.inserts) {
// Only the trigger has been inserted, or a simple abbreviation was just
// expanded. In either case, there is nothing to expand.
return
}
// The trigger is only used to determine word boundary; when considering
// what to expand, we only consider the part that was inserted before it.
inserts := w.inserts[:len(w.inserts)-triggerLen]
var abbr, full string
// Find the longest matching abbreviation.
w.SmallWordAbbreviations(func(a, f string) {
if len(a) <= len(abbr) {
// This abbreviation can't be the longest.
return
}
if !strings.HasSuffix(inserts, a) {
// This abbreviation was not inserted.
return
}
// Verify the trigger rune creates a word boundary.
r, _ := utf8.DecodeLastRuneInString(a)
if categorizer(trigger) == categorizer(r) {
return
}
// Verify the rune preceding the abbreviation, if any, creates a word
// boundary.
if len(buf.Content) > len(a)+triggerLen {
r1, _ := utf8.DecodeLastRuneInString(buf.Content[:len(buf.Content)-len(a)-triggerLen])
r2, _ := utf8.DecodeRuneInString(a)
if categorizer(r1) == categorizer(r2) {
return
}
}
abbr, full = a, f
})
if len(abbr) > 0 {
*buf = CodeBuffer{
Content: buf.Content[:buf.Dot-len(abbr)-triggerLen] + full + string(trigger),
Dot: buf.Dot - len(abbr) + len(full),
}
w.resetInserts()
}
}
func (w *codeArea) handleKeyEvent(key ui.Key) bool {
isFuncKey := key.Mod != 0 || key.Rune < 0
if w.pasting {
if isFuncKey {
// TODO: Notify the user of the error, or insert the original
// character as is.
} else {
w.pasteBuffer.WriteRune(key.Rune)
}
return true
}
if w.Bindings.Handle(w, term.KeyEvent(key)) {
return true
}
// We only implement essential keybindings here. Other keybindings can be
// added via handler overlays.
switch key {
case ui.K('\n'):
w.resetInserts()
w.Submit()
return true
case ui.K(ui.Backspace), ui.K('H', ui.Ctrl):
w.resetInserts()
w.MutateState(func(s *CodeAreaState) {
c := &s.Buffer
// Remove the last rune.
_, chop := utf8.DecodeLastRuneInString(c.Content[:c.Dot])
*c = CodeBuffer{
Content: c.Content[:c.Dot-chop] + c.Content[c.Dot:],
Dot: c.Dot - chop,
}
})
return true
default:
if isFuncKey || !unicode.IsGraphic(key.Rune) {
w.resetInserts()
return false
}
w.StateMutex.Lock()
defer w.StateMutex.Unlock()
if w.lastCodeBuffer != w.State.Buffer {
// Something has happened between the last insert and this one;
// reset the state.
w.resetInserts()
}
s := string(key.Rune)
w.State.Buffer.InsertAtDot(s)
w.inserts += s
w.lastCodeBuffer = w.State.Buffer
if parse.IsWhitespace(key.Rune) {
w.expandCommandAbbr()
}
w.expandSimpleAbbr()
w.expandSmallWordAbbr(key.Rune, CategorizeSmallWord)
return true
}
}
// IsAlnum determines if the rune is an alphanumeric character.
func IsAlnum(r rune) bool {
return unicode.IsLetter(r) || unicode.IsNumber(r)
}
// CategorizeSmallWord determines if the rune is whitespace, alphanum, or
// something else.
func CategorizeSmallWord(r rune) int {
switch {
case unicode.IsSpace(r):
return 0
case IsAlnum(r):
return 1
default:
return 2
}
}
elvish-0.20.1/pkg/cli/tk/codearea_render.go 0000664 0000000 0000000 00000005461 14570151573 0020501 0 ustar 00root root 0000000 0000000 package tk
import (
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/ui"
"src.elv.sh/pkg/wcwidth"
)
// View model, calculated from State and used for rendering.
type view struct {
prompt ui.Text
rprompt ui.Text
code ui.Text
dot int
tips []ui.Text
}
var stylingForPending = ui.Underlined
func getView(w *codeArea) *view {
s := w.CopyState()
code, pFrom, pTo := patchPending(s.Buffer, s.Pending)
styledCode, errors := w.Highlighter(code.Content)
if s.HideTips {
errors = nil
}
if pFrom < pTo {
// Apply stylingForPending to [pFrom, pTo)
parts := styledCode.Partition(pFrom, pTo)
pending := ui.StyleText(parts[1], stylingForPending)
styledCode = ui.Concat(parts[0], pending, parts[2])
}
var rprompt ui.Text
if !s.HideRPrompt {
rprompt = w.RPrompt()
}
return &view{w.Prompt(), rprompt, styledCode, code.Dot, errors}
}
func patchPending(c CodeBuffer, p PendingCode) (CodeBuffer, int, int) {
if p.From > p.To || p.From < 0 || p.To > len(c.Content) {
// Invalid Pending.
return c, 0, 0
}
if p.From == p.To && p.Content == "" {
return c, 0, 0
}
newContent := c.Content[:p.From] + p.Content + c.Content[p.To:]
newDot := 0
switch {
case c.Dot < p.From:
// Dot is before the replaced region. Keep it.
newDot = c.Dot
case c.Dot >= p.From && c.Dot < p.To:
// Dot is within the replaced region. Place the dot at the end.
newDot = p.From + len(p.Content)
case c.Dot >= p.To:
// Dot is after the replaced region. Maintain the relative position of
// the dot.
newDot = c.Dot - (p.To - p.From) + len(p.Content)
}
return CodeBuffer{Content: newContent, Dot: newDot}, p.From, p.From + len(p.Content)
}
func renderView(v *view, buf *term.BufferBuilder) {
buf.EagerWrap = true
buf.WriteStyled(v.prompt)
if len(buf.Lines) == 1 && buf.Col*2 < buf.Width {
buf.Indent = buf.Col
}
parts := v.code.Partition(v.dot)
buf.
WriteStyled(parts[0]).
SetDotHere().
WriteStyled(parts[1])
buf.EagerWrap = false
buf.Indent = 0
// Handle rprompts with newlines.
if rpromptWidth := styledWcswidth(v.rprompt); rpromptWidth > 0 {
padding := buf.Width - buf.Col - rpromptWidth
if padding >= 1 {
buf.WriteSpaces(padding)
buf.WriteStyled(v.rprompt)
}
}
for _, tip := range v.tips {
buf.Newline()
buf.WriteStyled(tip)
}
}
func truncateToHeight(b *term.Buffer, maxHeight int) {
switch {
case len(b.Lines) <= maxHeight:
// We can show all line; do nothing.
case b.Dot.Line < maxHeight:
// We can show all lines before the cursor, and as many lines after the
// cursor as we can, adding up to maxHeight.
b.TrimToLines(0, maxHeight)
default:
// We can show maxHeight lines before and including the cursor line.
b.TrimToLines(b.Dot.Line-maxHeight+1, b.Dot.Line+1)
}
}
func styledWcswidth(t ui.Text) int {
w := 0
for _, seg := range t {
w += wcwidth.Of(seg.Text)
}
return w
}
elvish-0.20.1/pkg/cli/tk/codearea_test.go 0000664 0000000 0000000 00000041233 14570151573 0020176 0 ustar 00root root 0000000 0000000 package tk
import (
"reflect"
"testing"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/tt"
"src.elv.sh/pkg/ui"
)
var Args = tt.Args
var bb = term.NewBufferBuilder
func p(t ui.Text) func() ui.Text { return func() ui.Text { return t } }
var codeAreaRenderTests = []renderTest{
{
Name: "prompt only",
Given: NewCodeArea(CodeAreaSpec{
Prompt: p(ui.T("~>", ui.Bold))}),
Width: 10, Height: 24,
Want: bb(10).WriteStringSGR("~>", "1").SetDotHere(),
},
{
Name: "rprompt only",
Given: NewCodeArea(CodeAreaSpec{
RPrompt: p(ui.T("RP", ui.Inverse))}),
Width: 10, Height: 24,
Want: bb(10).SetDotHere().WriteSpaces(8).WriteStringSGR("RP", "7"),
},
{
Name: "code only with dot at beginning",
Given: NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "code", Dot: 0}}}),
Width: 10, Height: 24,
Want: bb(10).SetDotHere().Write("code"),
},
{
Name: "code only with dot at middle",
Given: NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "code", Dot: 2}}}),
Width: 10, Height: 24,
Want: bb(10).Write("co").SetDotHere().Write("de"),
},
{
Name: "code only with dot at end",
Given: NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "code", Dot: 4}}}),
Width: 10, Height: 24,
Want: bb(10).Write("code").SetDotHere(),
},
{
Name: "prompt, code and rprompt",
Given: NewCodeArea(CodeAreaSpec{
Prompt: p(ui.T("~>")),
RPrompt: p(ui.T("RP")),
State: CodeAreaState{Buffer: CodeBuffer{Content: "code", Dot: 4}}}),
Width: 10, Height: 24,
Want: bb(10).Write("~>code").SetDotHere().Write(" RP"),
},
{
Name: "prompt explicitly hidden ",
Given: NewCodeArea(CodeAreaSpec{
Prompt: p(ui.T("~>")),
RPrompt: p(ui.T("RP")),
State: CodeAreaState{Buffer: CodeBuffer{Content: "code", Dot: 4}, HideRPrompt: true}}),
Width: 10, Height: 24,
Want: bb(10).Write("~>code").SetDotHere(),
},
{
Name: "rprompt too long",
Given: NewCodeArea(CodeAreaSpec{
Prompt: p(ui.T("~>")),
RPrompt: p(ui.T("1234")),
State: CodeAreaState{Buffer: CodeBuffer{Content: "code", Dot: 4}}}),
Width: 10, Height: 24,
Want: bb(10).Write("~>code").SetDotHere(),
},
{
Name: "highlighted code",
Given: NewCodeArea(CodeAreaSpec{
Highlighter: func(code string) (ui.Text, []ui.Text) {
return ui.T(code, ui.Bold), nil
},
State: CodeAreaState{Buffer: CodeBuffer{Content: "code", Dot: 4}}}),
Width: 10, Height: 24,
Want: bb(10).WriteStringSGR("code", "1").SetDotHere(),
},
{
Name: "tips",
Given: NewCodeArea(CodeAreaSpec{
Prompt: p(ui.T("> ")),
Highlighter: func(code string) (ui.Text, []ui.Text) {
return ui.T(code), []ui.Text{ui.T("static error")}
},
State: CodeAreaState{Buffer: CodeBuffer{Content: "code", Dot: 4}}}),
Width: 10, Height: 24,
Want: bb(10).Write("> code").SetDotHere().
Newline().Write("static error"),
},
{
Name: "hiding tips",
Given: NewCodeArea(CodeAreaSpec{
Prompt: p(ui.T("> ")),
Highlighter: func(code string) (ui.Text, []ui.Text) {
return ui.T(code), []ui.Text{ui.T("static error")}
},
State: CodeAreaState{
Buffer: CodeBuffer{Content: "code", Dot: 4}, HideTips: true}}),
Width: 10, Height: 24,
Want: bb(10).Write("> code").SetDotHere(),
},
{
Name: "pending code inserting at the dot",
Given: NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "code", Dot: 4},
Pending: PendingCode{From: 4, To: 4, Content: "x"},
}}),
Width: 10, Height: 24,
Want: bb(10).Write("code").WriteStringSGR("x", "4").SetDotHere(),
},
{
Name: "pending code replacing at the dot",
Given: NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "code", Dot: 2},
Pending: PendingCode{From: 2, To: 4, Content: "x"},
}}),
Width: 10, Height: 24,
Want: bb(10).Write("co").WriteStringSGR("x", "4").SetDotHere(),
},
{
Name: "pending code to the left of the dot",
Given: NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "code", Dot: 4},
Pending: PendingCode{From: 1, To: 3, Content: "x"},
}}),
Width: 10, Height: 24,
Want: bb(10).Write("c").WriteStringSGR("x", "4").Write("e").SetDotHere(),
},
{
Name: "pending code to the right of the cursor",
Given: NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "code", Dot: 1},
Pending: PendingCode{From: 2, To: 3, Content: "x"},
}}),
Width: 10, Height: 24,
Want: bb(10).Write("c").SetDotHere().Write("o").
WriteStringSGR("x", "4").Write("e"),
},
{
Name: "ignore invalid pending code 1",
Given: NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "code", Dot: 4},
Pending: PendingCode{From: 2, To: 1, Content: "x"},
}}),
Width: 10, Height: 24,
Want: bb(10).Write("code").SetDotHere(),
},
{
Name: "ignore invalid pending code 2",
Given: NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "code", Dot: 4},
Pending: PendingCode{From: 5, To: 6, Content: "x"},
}}),
Width: 10, Height: 24,
Want: bb(10).Write("code").SetDotHere(),
},
{
Name: "prioritize lines before the cursor with small height",
Given: NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "a\nb\nc\nd", Dot: 3},
}}),
Width: 10, Height: 2,
Want: bb(10).Write("a").Newline().Write("b").SetDotHere(),
},
{
Name: "show only the cursor line when height is 1",
Given: NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "a\nb\nc\nd", Dot: 3},
}}),
Width: 10, Height: 1,
Want: bb(10).Write("b").SetDotHere(),
},
{
Name: "show lines after the cursor when all lines before the cursor are shown",
Given: NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "a\nb\nc\nd", Dot: 3},
}}),
Width: 10, Height: 3,
Want: bb(10).Write("a").Newline().Write("b").SetDotHere().
Newline().Write("c"),
},
}
func TestCodeArea_Render(t *testing.T) {
testRender(t, codeAreaRenderTests)
}
var codeAreaHandleTests = []handleTest{
{
Name: "simple inserts",
Given: NewCodeArea(CodeAreaSpec{}),
Events: []term.Event{term.K('c'), term.K('o'), term.K('d'), term.K('e')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "code", Dot: 4}},
},
{
Name: "unicode inserts",
Given: NewCodeArea(CodeAreaSpec{}),
Events: []term.Event{term.K('你'), term.K('好')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "你好", Dot: 6}},
},
{
Name: "unterminated paste",
Given: NewCodeArea(CodeAreaSpec{}),
Events: []term.Event{term.PasteSetting(true), term.K('"'), term.K('x')},
WantNewState: CodeAreaState{},
},
{
Name: "literal paste",
Given: NewCodeArea(CodeAreaSpec{}),
Events: []term.Event{
term.PasteSetting(true),
term.K('"'), term.K('x'),
term.PasteSetting(false)},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "\"x", Dot: 2}},
},
{
Name: "literal paste swallowing functional keys",
Given: NewCodeArea(CodeAreaSpec{}),
Events: []term.Event{
term.PasteSetting(true),
term.K('a'), term.K(ui.F1), term.K('b'),
term.PasteSetting(false)},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "ab", Dot: 2}},
},
{
Name: "quoted paste",
Given: NewCodeArea(CodeAreaSpec{QuotePaste: func() bool { return true }}),
Events: []term.Event{
term.PasteSetting(true),
term.K('"'), term.K('x'),
term.PasteSetting(false)},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "'\"x'", Dot: 4}},
},
{
Name: "backspace at end of code",
Given: NewCodeArea(CodeAreaSpec{}),
Events: []term.Event{
term.K('c'), term.K('o'), term.K('d'), term.K('e'),
term.K(ui.Backspace)},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "cod", Dot: 3}},
},
{
Name: "backspace at middle of buffer",
Given: NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "code", Dot: 2}}}),
Events: []term.Event{term.K(ui.Backspace)},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "cde", Dot: 1}},
},
{
Name: "backspace at beginning of buffer",
Given: NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "code", Dot: 0}}}),
Events: []term.Event{term.K(ui.Backspace)},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "code", Dot: 0}},
},
{
Name: "backspace deleting unicode character",
Given: NewCodeArea(CodeAreaSpec{}),
Events: []term.Event{
term.K('你'), term.K('好'), term.K(ui.Backspace)},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "你", Dot: 3}},
},
// Regression test for https://b.elv.sh/1178
{
Name: "Ctrl-H being equivalent to backspace",
Given: NewCodeArea(CodeAreaSpec{}),
Events: []term.Event{
term.K('c'), term.K('o'), term.K('d'), term.K('e'),
term.K('H', ui.Ctrl)},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "cod", Dot: 3}},
},
{
Name: "abbreviation expansion",
Given: NewCodeArea(CodeAreaSpec{
SimpleAbbreviations: func(f func(abbr, full string)) {
f("dn", "/dev/null")
},
}),
Events: []term.Event{term.K('d'), term.K('n')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "/dev/null", Dot: 9}},
},
{
Name: "abbreviation expansion 2",
Given: NewCodeArea(CodeAreaSpec{
SimpleAbbreviations: func(f func(abbr, full string)) {
f("||", " | less")
},
}),
Events: []term.Event{term.K('x'), term.K('|'), term.K('|')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "x | less", Dot: 8}},
},
{
Name: "abbreviation expansion after other content",
Given: NewCodeArea(CodeAreaSpec{
SimpleAbbreviations: func(f func(abbr, full string)) {
f("||", " | less")
},
}),
Events: []term.Event{term.K('{'), term.K('e'), term.K('c'), term.K('h'), term.K('o'), term.K(' '), term.K('x'), term.K('}'), term.K('|'), term.K('|')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "{echo x} | less", Dot: 15}},
},
{
Name: "abbreviation expansion preferring longest",
Given: NewCodeArea(CodeAreaSpec{
SimpleAbbreviations: func(f func(abbr, full string)) {
f("n", "none")
f("dn", "/dev/null")
},
}),
Events: []term.Event{term.K('d'), term.K('n')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "/dev/null", Dot: 9}},
},
{
Name: "abbreviation expansion interrupted by function key",
Given: NewCodeArea(CodeAreaSpec{
SimpleAbbreviations: func(f func(abbr, full string)) {
f("dn", "/dev/null")
},
}),
Events: []term.Event{term.K('d'), term.K(ui.F1), term.K('n')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "dn", Dot: 2}},
},
{
Name: "small word abbreviation expansion space trigger",
Given: NewCodeArea(CodeAreaSpec{
SmallWordAbbreviations: func(f func(abbr, full string)) {
f("eh", "echo hello")
},
}),
Events: []term.Event{term.K('e'), term.K('h'), term.K(' ')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "echo hello ", Dot: 11}},
},
{
Name: "small word abbreviation expansion non-space trigger",
Given: NewCodeArea(CodeAreaSpec{
SmallWordAbbreviations: func(f func(abbr, full string)) {
f("h", "hello")
},
}),
Events: []term.Event{term.K('x'), term.K('['), term.K('h'), term.K(']')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "x[hello]", Dot: 8}},
},
{
Name: "small word abbreviation expansion preceding char invalid",
Given: NewCodeArea(CodeAreaSpec{
SmallWordAbbreviations: func(f func(abbr, full string)) {
f("h", "hello")
},
}),
Events: []term.Event{term.K('g'), term.K('h'), term.K(' ')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "gh ", Dot: 3}},
},
{
Name: "small word abbreviation expansion after backspace preceding char invalid",
Given: NewCodeArea(CodeAreaSpec{
SmallWordAbbreviations: func(f func(abbr, full string)) {
f("h", "hello")
},
}),
Events: []term.Event{term.K('g'), term.K(' '), term.K(ui.Backspace),
term.K('h'), term.K(' ')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "gh ", Dot: 3}},
},
{
Name: "command abbreviation expansion",
Given: NewCodeArea(CodeAreaSpec{
CommandAbbreviations: func(f func(abbr, full string)) {
f("eh", "echo hello")
},
}),
Events: []term.Event{term.K('e'), term.K('h'), term.K(' ')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "echo hello ", Dot: 11}},
},
{
Name: "command abbreviation expansion not at start of line",
Given: NewCodeArea(CodeAreaSpec{
CommandAbbreviations: func(f func(abbr, full string)) {
f("eh", "echo hello")
},
}),
Events: []term.Event{term.K('x'), term.K('|'), term.K('e'), term.K('h'), term.K(' ')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "x|echo hello ", Dot: 13}},
},
{
Name: "command abbreviation expansion at start of second line",
Given: NewCodeArea(CodeAreaSpec{
CommandAbbreviations: func(f func(abbr, full string)) {
f("eh", "echo hello")
},
State: CodeAreaState{Buffer: CodeBuffer{Content: "echo\n", Dot: 5}},
}),
Events: []term.Event{term.K('e'), term.K('h'), term.K(' ')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "echo\necho hello ", Dot: 16}},
},
{
Name: "no command abbreviation expansion when not in command position",
Given: NewCodeArea(CodeAreaSpec{
CommandAbbreviations: func(f func(abbr, full string)) {
f("eh", "echo hello")
},
}),
Events: []term.Event{term.K('x'), term.K(' '), term.K('e'), term.K('h'), term.K(' ')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "x eh ", Dot: 5}},
},
{
Name: "key bindings",
Given: NewCodeArea(CodeAreaSpec{Bindings: MapBindings{
term.K('a'): func(w Widget) {
w.(*codeArea).State.Buffer.InsertAtDot("b")
}},
}),
Events: []term.Event{term.K('a')},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "b", Dot: 1}},
},
{
// Regression test for #890.
Name: "key bindings do not apply when pasting",
Given: NewCodeArea(CodeAreaSpec{Bindings: MapBindings{
term.K('\n'): func(w Widget) {}},
}),
Events: []term.Event{
term.PasteSetting(true), term.K('\n'), term.PasteSetting(false)},
WantNewState: CodeAreaState{Buffer: CodeBuffer{Content: "\n", Dot: 1}},
},
}
func TestCodeArea_Handle(t *testing.T) {
testHandle(t, codeAreaHandleTests)
}
var codeAreaUnhandledEvents = []term.Event{
// Mouse events are unhandled
term.MouseEvent{},
// Function keys are unhandled (except Backspace)
term.K(ui.F1),
term.K('X', ui.Ctrl),
}
func TestCodeArea_Handle_UnhandledEvents(t *testing.T) {
w := NewCodeArea(CodeAreaSpec{})
for _, event := range codeAreaUnhandledEvents {
handled := w.Handle(event)
if handled {
t.Errorf("event %v got handled", event)
}
}
}
func TestCodeArea_Handle_AbbreviationExpansionInterruptedByExternalMutation(t *testing.T) {
w := NewCodeArea(CodeAreaSpec{
SimpleAbbreviations: func(f func(abbr, full string)) {
f("dn", "/dev/null")
},
})
w.Handle(term.K('d'))
w.MutateState(func(s *CodeAreaState) { s.Buffer.InsertAtDot("d") })
w.Handle(term.K('n'))
wantState := CodeAreaState{Buffer: CodeBuffer{Content: "ddn", Dot: 3}}
if state := w.CopyState(); !reflect.DeepEqual(state, wantState) {
t.Errorf("got state %v, want %v", state, wantState)
}
}
func TestCodeArea_Handle_EnterEmitsSubmit(t *testing.T) {
submitted := false
w := NewCodeArea(CodeAreaSpec{
OnSubmit: func() { submitted = true },
State: CodeAreaState{Buffer: CodeBuffer{Content: "code", Dot: 4}}})
w.Handle(term.K('\n'))
if submitted != true {
t.Errorf("OnSubmit not triggered")
}
}
func TestCodeArea_Handle_DefaultNoopSubmit(t *testing.T) {
w := NewCodeArea(CodeAreaSpec{State: CodeAreaState{
Buffer: CodeBuffer{Content: "code", Dot: 4}}})
w.Handle(term.K('\n'))
// No panic, we are good
}
func TestCodeArea_State(t *testing.T) {
w := NewCodeArea(CodeAreaSpec{})
w.MutateState(func(s *CodeAreaState) { s.Buffer.Content = "code" })
if w.CopyState().Buffer.Content != "code" {
t.Errorf("state not mutated")
}
}
func TestCodeAreaState_ApplyPending(t *testing.T) {
applyPending := func(s CodeAreaState) CodeAreaState {
s.ApplyPending()
return s
}
tt.Test(t, applyPending,
Args(CodeAreaState{Buffer: CodeBuffer{}, Pending: PendingCode{0, 0, "ls"}}).
Rets(CodeAreaState{Buffer: CodeBuffer{Content: "ls", Dot: 2}, Pending: PendingCode{}}),
Args(CodeAreaState{Buffer: CodeBuffer{"x", 1}, Pending: PendingCode{0, 0, "ls"}}).
Rets(CodeAreaState{Buffer: CodeBuffer{Content: "lsx", Dot: 3}, Pending: PendingCode{}}),
// No-op when Pending is empty.
Args(CodeAreaState{Buffer: CodeBuffer{"x", 1}}).
Rets(CodeAreaState{Buffer: CodeBuffer{Content: "x", Dot: 1}}),
// HideRPrompt is kept intact.
Args(CodeAreaState{Buffer: CodeBuffer{"x", 1}, HideRPrompt: true}).
Rets(CodeAreaState{Buffer: CodeBuffer{Content: "x", Dot: 1}, HideRPrompt: true}),
)
}
elvish-0.20.1/pkg/cli/tk/colview.go 0000664 0000000 0000000 00000012305 14570151573 0017042 0 ustar 00root root 0000000 0000000 package tk
import (
"sync"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/ui"
)
// ColView is a Widget that arranges several widgets in a column.
type ColView interface {
Widget
// MutateState mutates the state.
MutateState(f func(*ColViewState))
// CopyState returns a copy of the state.
CopyState() ColViewState
// Left triggers the OnLeft callback.
Left()
// Right triggers the OnRight callback.
Right()
}
// ColViewSpec specifies the configuration and initial state for ColView.
type ColViewSpec struct {
// Key bindings.
Bindings Bindings
// A function that takes the number of columns and return weights for the
// widths of the columns. The returned slice must have a size of n. If this
// function is nil, all the columns will have the same weight.
Weights func(n int) []int
// A function called when the Left method of Widget is called, or when Left
// is pressed and unhandled.
OnLeft func(w ColView)
// A function called when the Right method of Widget is called, or when
// Right is pressed and unhandled.
OnRight func(w ColView)
// State. Specifies the initial state when used in New.
State ColViewState
}
// ColViewState keeps the mutable state of the ColView widget.
type ColViewState struct {
Columns []Widget
FocusColumn int
}
type colView struct {
// Mutex for synchronizing access to State.
StateMutex sync.RWMutex
ColViewSpec
}
// NewColView creates a new ColView from the given spec.
func NewColView(spec ColViewSpec) ColView {
if spec.Bindings == nil {
spec.Bindings = DummyBindings{}
}
if spec.Weights == nil {
spec.Weights = equalWeights
}
if spec.OnLeft == nil {
spec.OnLeft = func(ColView) {}
}
if spec.OnRight == nil {
spec.OnRight = func(ColView) {}
}
return &colView{ColViewSpec: spec}
}
func equalWeights(n int) []int {
weights := make([]int, n)
for i := 0; i < n; i++ {
weights[i] = 1
}
return weights
}
func (w *colView) MutateState(f func(*ColViewState)) {
w.StateMutex.Lock()
defer w.StateMutex.Unlock()
f(&w.State)
}
func (w *colView) CopyState() ColViewState {
w.StateMutex.RLock()
defer w.StateMutex.RUnlock()
copied := w.State
copied.Columns = append([]Widget(nil), w.State.Columns...)
return copied
}
const colViewColGap = 1
// Render renders all the columns side by side, putting the dot in the focused
// column.
func (w *colView) Render(width, height int) *term.Buffer {
cols, widths := w.prepareRender(width)
if len(cols) == 0 {
return &term.Buffer{Width: width}
}
var buf term.Buffer
for i, col := range cols {
if i > 0 {
buf.Width += colViewColGap
}
bufCol := col.Render(widths[i], height)
buf.ExtendRight(bufCol)
}
return &buf
}
func (w *colView) MaxHeight(width, height int) int {
cols, widths := w.prepareRender(width)
max := 0
for i, col := range cols {
colMax := col.MaxHeight(widths[i], height)
if max < colMax {
max = colMax
}
}
return max
}
// Returns widgets in and widths of columns.
func (w *colView) prepareRender(width int) ([]Widget, []int) {
state := w.CopyState()
ncols := len(state.Columns)
if ncols == 0 {
// No column.
return nil, nil
}
if width < ncols {
// To narrow; give up by rendering nothing.
return nil, nil
}
widths := distribute(width-(ncols-1)*colViewColGap, w.Weights(ncols))
return state.Columns, widths
}
// Handle handles the event first by consulting the overlay handler, and then
// delegating the event to the currently focused column.
func (w *colView) Handle(event term.Event) bool {
if w.Bindings.Handle(w, event) {
return true
}
state := w.CopyState()
if 0 <= state.FocusColumn && state.FocusColumn < len(state.Columns) {
if state.Columns[state.FocusColumn].Handle(event) {
return true
}
}
switch event {
case term.K(ui.Left):
w.Left()
return true
case term.K(ui.Right):
w.Right()
return true
default:
return false
}
}
func (w *colView) Left() {
w.OnLeft(w)
}
func (w *colView) Right() {
w.OnRight(w)
}
// Distributes fullWidth according to the weights, rounding to integers.
//
// This works iteratively each step by taking the sum of all remaining weights,
// and using floor(remainedWidth * currentWeight / remainedAllWeights) for the
// current column.
//
// A simpler alternative is to simply use floor(fullWidth * currentWeight /
// allWeights) at each step, and also giving the remainder to the last column.
// However, this means that the last column gets all the rounding errors from
// flooring, which can be big. The more sophisticated algorithm distributes the
// rounding errors among all the remaining elements and can result in a much
// better distribution, and as a special upside, does not need to handle the
// last column as a special case.
//
// As an extreme example, consider the case of fullWidth = 9, weights = {1, 1,
// 1, 1, 1} (five 1's). Using the simplistic algorithm, the widths are {1, 1, 1,
// 1, 5}. Using the more complex algorithm, the widths are {1, 2, 2, 2, 2}.
func distribute(fullWidth int, weights []int) []int {
remainedWidth := fullWidth
remainedWeight := 0
for _, weight := range weights {
remainedWeight += weight
}
widths := make([]int, len(weights))
for i, weight := range weights {
widths[i] = remainedWidth * weight / remainedWeight
remainedWidth -= widths[i]
remainedWeight -= weight
}
return widths
}
elvish-0.20.1/pkg/cli/tk/colview_test.go 0000664 0000000 0000000 00000006505 14570151573 0020106 0 ustar 00root root 0000000 0000000 package tk
import (
"testing"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/tt"
"src.elv.sh/pkg/ui"
)
var colViewRenderTests = []renderTest{
{
Name: "colview no column",
Given: NewColView(ColViewSpec{}),
Width: 10, Height: 24,
Want: &term.Buffer{Width: 10},
},
{
Name: "colview width < number of columns",
Given: NewColView(ColViewSpec{State: ColViewState{
Columns: []Widget{
makeListbox("x", 2, 0), makeListbox("y", 1, 0),
makeListbox("z", 3, 0), makeListbox("w", 1, 0),
},
}}),
Width: 3, Height: 24,
Want: &term.Buffer{Width: 3},
},
{
Name: "colview normal",
Given: NewColView(ColViewSpec{State: ColViewState{
Columns: []Widget{
makeListbox("x", 2, 1),
makeListbox("y", 1, 0),
makeListbox("z", 3, -1),
},
}}),
Width: 11, Height: 24,
Want: term.NewBufferBuilder(11).
// first line
Write("x0 ").
Write("y0 ", ui.Inverse).
Write(" z0").
// second line
Newline().Write("x1 ", ui.Inverse).
Write(" z1").
// third line
Newline().Write(" z2"),
},
}
func makeListbox(prefix string, n, selected int) Widget {
return NewListBox(ListBoxSpec{
State: ListBoxState{
Items: TestItems{Prefix: prefix, NItems: n},
Selected: selected,
}})
}
func TestColView_Render(t *testing.T) {
testRender(t, colViewRenderTests)
}
func TestColView_Handle(t *testing.T) {
// Channel for recording the place an event was handled. -1 for the widget
// itself, column index for column.
handledBy := make(chan int, 10)
w := NewColView(ColViewSpec{
Bindings: MapBindings{
term.K('a'): func(Widget) { handledBy <- -1 },
},
State: ColViewState{
Columns: []Widget{
NewListBox(ListBoxSpec{
Bindings: MapBindings{
term.K('a'): func(Widget) { handledBy <- 0 },
term.K('b'): func(Widget) { handledBy <- 0 },
}}),
NewListBox(ListBoxSpec{
Bindings: MapBindings{
term.K('a'): func(Widget) { handledBy <- 1 },
term.K('b'): func(Widget) { handledBy <- 1 },
}}),
},
FocusColumn: 1,
},
OnLeft: func(ColView) { handledBy <- 100 },
OnRight: func(ColView) { handledBy <- 101 },
})
expectHandled := func(event term.Event, wantBy int) {
t.Helper()
handled := w.Handle(event)
if !handled {
t.Errorf("Handle -> false, want true")
}
if by := <-handledBy; by != wantBy {
t.Errorf("Handled by %d, want %d", by, wantBy)
}
}
expectUnhandled := func(event term.Event) {
t.Helper()
handled := w.Handle(event)
if handled {
t.Errorf("Handle -> true, want false")
}
}
// Event handled by widget's overlay handler.
expectHandled(term.K('a'), -1)
// Event handled by the focused column.
expectHandled(term.K('b'), 1)
// Fallback handler for Left
expectHandled(term.K(ui.Left), 100)
// Fallback handler for Left
expectHandled(term.K(ui.Right), 101)
// No one to handle the event.
expectUnhandled(term.K('c'))
// No focused column: event unhandled
w.MutateState(func(s *ColViewState) { s.FocusColumn = -1 })
expectUnhandled(term.K('b'))
}
func TestDistribute(t *testing.T) {
tt.Test(t, distribute,
// Nice integer distributions.
Args(10, []int{1, 1}).Rets([]int{5, 5}),
Args(10, []int{2, 3}).Rets([]int{4, 6}),
Args(10, []int{1, 2, 2}).Rets([]int{2, 4, 4}),
// Approximate integer distributions.
Args(10, []int{1, 1, 1}).Rets([]int{3, 3, 4}),
Args(5, []int{1, 1, 1}).Rets([]int{1, 2, 2}),
)
}
elvish-0.20.1/pkg/cli/tk/combobox.go 0000664 0000000 0000000 00000004103 14570151573 0017177 0 ustar 00root root 0000000 0000000 package tk
import (
"src.elv.sh/pkg/cli/term"
)
// ComboBox is a Widget that combines a ListBox and a CodeArea.
type ComboBox interface {
Widget
// Returns the embedded codearea widget.
CodeArea() CodeArea
// Returns the embedded listbox widget.
ListBox() ListBox
// Forces the filtering to rerun.
Refilter()
}
// ComboBoxSpec specifies the configuration and initial state for ComboBox.
type ComboBoxSpec struct {
CodeArea CodeAreaSpec
ListBox ListBoxSpec
OnFilter func(ComboBox, string)
}
type comboBox struct {
codeArea CodeArea
listBox ListBox
OnFilter func(ComboBox, string)
// Last filter value.
lastFilter string
}
// NewComboBox creates a new ComboBox from the given spec.
func NewComboBox(spec ComboBoxSpec) ComboBox {
if spec.OnFilter == nil {
spec.OnFilter = func(ComboBox, string) {}
}
w := &comboBox{
codeArea: NewCodeArea(spec.CodeArea),
listBox: NewListBox(spec.ListBox),
OnFilter: spec.OnFilter,
}
w.OnFilter(w, "")
return w
}
// Render renders the codearea and the listbox below it.
func (w *comboBox) Render(width, height int) *term.Buffer {
buf := w.codeArea.Render(width, height)
bufListBox := w.listBox.Render(width, height-len(buf.Lines))
buf.Extend(bufListBox, false)
return buf
}
func (w *comboBox) MaxHeight(width, height int) int {
return w.codeArea.MaxHeight(width, height) + w.listBox.MaxHeight(width, height)
}
// Handle first lets the listbox handle the event, and if it is unhandled, lets
// the codearea handle it. If the codearea has handled the event and the code
// content has changed, it calls OnFilter with the new content.
func (w *comboBox) Handle(event term.Event) bool {
if w.listBox.Handle(event) {
return true
}
if w.codeArea.Handle(event) {
filter := w.codeArea.CopyState().Buffer.Content
if filter != w.lastFilter {
w.OnFilter(w, filter)
w.lastFilter = filter
}
return true
}
return false
}
func (w *comboBox) Refilter() {
w.OnFilter(w, w.codeArea.CopyState().Buffer.Content)
}
func (w *comboBox) CodeArea() CodeArea { return w.codeArea }
func (w *comboBox) ListBox() ListBox { return w.listBox }
elvish-0.20.1/pkg/cli/tk/combobox_test.go 0000664 0000000 0000000 00000005351 14570151573 0020244 0 ustar 00root root 0000000 0000000 package tk
import (
"testing"
"time"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/ui"
)
var comboBoxRenderTests = []renderTest{
{
Name: "rendering codearea and listbox",
Given: NewComboBox(ComboBoxSpec{
CodeArea: CodeAreaSpec{
State: CodeAreaState{
Buffer: CodeBuffer{Content: "filter", Dot: 6}}},
ListBox: ListBoxSpec{
State: ListBoxState{Items: TestItems{NItems: 2}}}}),
Width: 10, Height: 24,
Want: term.NewBufferBuilder(10).
Write("filter").SetDotHere().
Newline().Write("item 0 ", ui.Inverse).
Newline().Write("item 1"),
},
{
Name: "calling filter before rendering",
Given: NewComboBox(ComboBoxSpec{
CodeArea: CodeAreaSpec{
State: CodeAreaState{
Buffer: CodeBuffer{Content: "filter", Dot: 6}}},
OnFilter: func(w ComboBox, filter string) {
w.ListBox().Reset(TestItems{NItems: 2}, 0)
}}),
Width: 10, Height: 24,
Want: term.NewBufferBuilder(10).
Write("filter").SetDotHere().
Newline().Write("item 0 ", ui.Inverse).
Newline().Write("item 1"),
},
}
func TestComboBox_Render(t *testing.T) {
testRender(t, comboBoxRenderTests)
}
func TestComboBox_Handle(t *testing.T) {
var onFilterCalled bool
var lastFilter string
w := NewComboBox(ComboBoxSpec{
OnFilter: func(w ComboBox, filter string) {
onFilterCalled = true
lastFilter = filter
},
ListBox: ListBoxSpec{
State: ListBoxState{Items: TestItems{NItems: 2}}}})
handled := w.Handle(term.K(ui.Down))
if !handled {
t.Errorf("listbox did not handle")
}
if w.ListBox().CopyState().Selected != 1 {
t.Errorf("listbox state not changed")
}
handled = w.Handle(term.K('a'))
if !handled {
t.Errorf("codearea did not handle letter key")
}
if w.CodeArea().CopyState().Buffer.Content != "a" {
t.Errorf("codearea state not changed")
}
if lastFilter != "a" {
t.Errorf("OnFilter not called when codearea content changed")
}
onFilterCalled = false
handled = w.Handle(term.PasteSetting(true))
if !handled {
t.Errorf("codearea did not handle PasteSetting")
}
if onFilterCalled {
t.Errorf("OnFilter called when codearea content did not change")
}
w.Handle(term.PasteSetting(false))
handled = w.Handle(term.K('D', ui.Ctrl))
if handled {
t.Errorf("key unhandled by codearea and listbox got handled")
}
}
func TestRefilter(t *testing.T) {
onFilter := make(chan string, 100)
w := NewComboBox(ComboBoxSpec{
OnFilter: func(w ComboBox, filter string) {
onFilter <- filter
}})
<-onFilter // Ignore the initial OnFilter call.
w.CodeArea().MutateState(func(s *CodeAreaState) { s.Buffer.Content = "new" })
w.Refilter()
select {
case f := <-onFilter:
if f != "new" {
t.Errorf("OnFilter called with %q, want 'new'", f)
}
case <-time.After(time.Second):
t.Errorf("OnFilter not called by Refilter")
}
}
elvish-0.20.1/pkg/cli/tk/empty.go 0000664 0000000 0000000 00000000772 14570151573 0016535 0 ustar 00root root 0000000 0000000 package tk
import (
"src.elv.sh/pkg/cli/term"
)
// Empty is an empty widget.
type Empty struct{}
// Render shows nothing, although the resulting Buffer still occupies one line.
func (Empty) Render(width, height int) *term.Buffer {
return term.NewBufferBuilder(width).Buffer()
}
// MaxHeight returns 1, since this widget always occupies one line.
func (Empty) MaxHeight(width, height int) int {
return 1
}
// Handle always returns false.
func (Empty) Handle(event term.Event) bool {
return false
}
elvish-0.20.1/pkg/cli/tk/label.go 0000664 0000000 0000000 00000001471 14570151573 0016453 0 ustar 00root root 0000000 0000000 package tk
import (
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/ui"
)
// Label is a Renderer that writes out a text.
type Label struct {
Content ui.Text
}
// Render shows the content. If the given box is too small, the text is cropped.
func (l Label) Render(width, height int) *term.Buffer {
// TODO: Optimize by stopping as soon as $height rows are written.
b := l.render(width)
b.TrimToLines(0, height)
return b
}
// MaxHeight returns the maximum height the Label can take when rendering within
// a bound box.
func (l Label) MaxHeight(width, height int) int {
return len(l.render(width).Lines)
}
func (l Label) render(width int) *term.Buffer {
return term.NewBufferBuilder(width).WriteStyled(l.Content).Buffer()
}
// Handle always returns false.
func (l Label) Handle(event term.Event) bool {
return false
}
elvish-0.20.1/pkg/cli/tk/layout_test.go 0000664 0000000 0000000 00000004711 14570151573 0017750 0 ustar 00root root 0000000 0000000 package tk
import (
"reflect"
"testing"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/ui"
)
var layoutRenderTests = []struct {
name string
renderer Renderer
width int
height int
wantBuf *term.BufferBuilder
}{
{
"empty widget",
Empty{},
10, 24,
bb(10),
},
{
"Label showing all",
Label{ui.T("label")},
10, 24,
bb(10).Write("label"),
},
{
"Label cropping",
Label{ui.T("label")},
4, 1,
bb(4).Write("labe"),
},
{
"VScrollbar showing full thumb",
VScrollbar{4, 0, 3},
10, 2,
bb(1).WriteStyled(vscrollbarThumb).WriteStyled(vscrollbarThumb),
},
{
"VScrollbar showing thumb in first half",
VScrollbar{4, 0, 1},
10, 2,
bb(1).WriteStyled(vscrollbarThumb).WriteStyled(vscrollbarTrough),
},
{
"VScrollbar showing a minimal 1-size thumb at beginning",
VScrollbar{4, 0, 0},
10, 2,
bb(1).WriteStyled(vscrollbarThumb).WriteStyled(vscrollbarTrough),
},
{
"VScrollbar showing a minimal 1-size thumb at end",
VScrollbar{4, 3, 3},
10, 2,
bb(1).WriteStyled(vscrollbarTrough).WriteStyled(vscrollbarThumb),
},
{
"VScrollbarContainer",
VScrollbarContainer{Label{ui.T("abcd1234")},
VScrollbar{4, 0, 1}},
5, 2,
bb(5).Write("abcd").WriteStyled(vscrollbarThumb).
Newline().Write("1234").WriteStyled(vscrollbarTrough),
},
{
"HScrollbar showing full thumb",
HScrollbar{4, 0, 3},
2, 10,
bb(2).WriteStyled(hscrollbarThumb).WriteStyled(hscrollbarThumb),
},
{
"HScrollbar showing thumb in first half",
HScrollbar{4, 0, 1},
2, 10,
bb(2).WriteStyled(hscrollbarThumb).WriteStyled(hscrollbarTrough),
},
{
"HScrollbar showing a minimal 1-size thumb at beginning",
HScrollbar{4, 0, 0},
2, 10,
bb(2).WriteStyled(hscrollbarThumb).WriteStyled(hscrollbarTrough),
},
{
"HScrollbar showing a minimal 1-size thumb at end",
HScrollbar{4, 3, 3},
2, 10,
bb(2).WriteStyled(hscrollbarTrough).WriteStyled(hscrollbarThumb),
},
}
func TestLayout_Render(t *testing.T) {
for _, test := range layoutRenderTests {
t.Run(test.name, func(t *testing.T) {
buf := test.renderer.Render(test.width, test.height)
wantBuf := test.wantBuf.Buffer()
if !reflect.DeepEqual(buf, wantBuf) {
t.Errorf("got buf %v, want %v", buf, wantBuf)
}
})
}
}
var nopHandlers = []Handler{
Empty{}, Label{ui.T("label")},
}
func TestLayout_Handle(t *testing.T) {
for _, handler := range nopHandlers {
handled := handler.Handle(term.K('a'))
if handled {
t.Errorf("%v handles event when it shouldn't", handler)
}
}
}
elvish-0.20.1/pkg/cli/tk/listbox.go 0000664 0000000 0000000 00000025433 14570151573 0017064 0 ustar 00root root 0000000 0000000 package tk
import (
"strings"
"sync"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/ui"
)
// ListBox is a list for displaying and selecting from a list of items.
type ListBox interface {
Widget
// CopyState returns a copy of the state.
CopyState() ListBoxState
// Reset resets the state of the widget with the given items and index of
// the selected item. It triggers the OnSelect callback if the index is
// valid.
Reset(it Items, selected int)
// Select changes the selection by calling f with the current state, and
// using the return value as the new selection index. It triggers the
// OnSelect callback if the selected index has changed and is valid.
Select(f func(ListBoxState) int)
// Accept accepts the currently selected item.
Accept()
}
// ListBoxSpec specifies the configuration and initial state for ListBox.
type ListBoxSpec struct {
// Key bindings.
Bindings Bindings
// A placeholder to show when there are no items.
Placeholder ui.Text
// A function to call when the selected item has changed.
OnSelect func(it Items, i int)
// A function called on the accept event.
OnAccept func(it Items, i int)
// Whether the listbox should be rendered in a horizontal layout. Note that
// in the horizontal layout, items must have only one line.
Horizontal bool
// The minimal amount of space to reserve for left and right sides of each
// entry.
Padding int
// If true, the left padding of each item will be styled the same as the
// first segment of the item, and the right spacing and padding will be
// styled the same as the last segment of the item.
ExtendStyle bool
// State. When used in [NewListBox], this field specifies the initial state.
State ListBoxState
}
type listBox struct {
// Mutex for synchronizing access to the state.
StateMutex sync.RWMutex
// Configuration and state.
ListBoxSpec
}
// NewListBox creates a new ListBox from the given spec.
func NewListBox(spec ListBoxSpec) ListBox {
if spec.Bindings == nil {
spec.Bindings = DummyBindings{}
}
if spec.OnAccept == nil {
spec.OnAccept = func(Items, int) {}
}
if spec.OnSelect == nil {
spec.OnSelect = func(Items, int) {}
} else {
s := spec.State
if s.Items != nil && 0 <= s.Selected && s.Selected < s.Items.Len() {
spec.OnSelect(s.Items, s.Selected)
}
}
return &listBox{ListBoxSpec: spec}
}
var stylingForSelected = ui.Inverse
func (w *listBox) Render(width, height int) *term.Buffer {
if w.Horizontal {
return w.renderHorizontal(width, height)
}
return w.renderVertical(width, height)
}
func (w *listBox) MaxHeight(width, height int) int {
s := w.CopyState()
if s.Items == nil || s.Items.Len() == 0 {
return 0
}
if w.Horizontal {
_, h, scrollbar := getHorizontalWindow(s, w.Padding, width, height)
if scrollbar {
return h + 1
}
return h
}
h := 0
for i := 0; i < s.Items.Len(); i++ {
h += s.Items.Show(i).CountLines()
if h >= height {
return height
}
}
return h
}
const listBoxColGap = 2
func (w *listBox) renderHorizontal(width, height int) *term.Buffer {
var state ListBoxState
var colHeight int
w.mutate(func(s *ListBoxState) {
if s.Items == nil || s.Items.Len() == 0 {
s.First = 0
} else {
s.First, s.ContentHeight, _ = getHorizontalWindow(*s, w.Padding, width, height)
colHeight = s.ContentHeight
}
state = *s
})
if state.Items == nil || state.Items.Len() == 0 {
return Label{Content: w.Placeholder}.Render(width, height)
}
items, selected, first := state.Items, state.Selected, state.First
n := items.Len()
buf := term.NewBuffer(0)
remainedWidth := width
hasCropped := false
last := first
for i := first; i < n; i += colHeight {
selectedRow := -1
// Render the column starting from i.
col := make([]ui.Text, 0, colHeight)
for j := i; j < i+colHeight && j < n; j++ {
last = j
item := items.Show(j)
if j == selected {
selectedRow = j - i
}
col = append(col, item)
}
colWidth := maxWidth(items, w.Padding, i, i+colHeight)
if colWidth > remainedWidth {
colWidth = remainedWidth
hasCropped = true
}
colBuf := croppedLines{
lines: col, padding: w.Padding,
selectFrom: selectedRow, selectTo: selectedRow + 1,
extendStyle: w.ExtendStyle}.Render(colWidth, colHeight)
buf.ExtendRight(colBuf)
remainedWidth -= colWidth
if remainedWidth <= listBoxColGap {
break
}
remainedWidth -= listBoxColGap
buf.Width += listBoxColGap
}
// We may not have used all the width required; force buffer width.
buf.Width = width
if colHeight < height && (first != 0 || last != n-1 || hasCropped) {
scrollbar := HScrollbar{Total: n, Low: first, High: last + 1}
buf.Extend(scrollbar.Render(width, 1), false)
}
return buf
}
func (w *listBox) renderVertical(width, height int) *term.Buffer {
var state ListBoxState
var firstCrop int
w.mutate(func(s *ListBoxState) {
if s.Items == nil || s.Items.Len() == 0 {
s.First = 0
} else {
s.First, firstCrop = getVerticalWindow(*s, height)
}
s.ContentHeight = height
state = *s
})
if state.Items == nil || state.Items.Len() == 0 {
return Label{Content: w.Placeholder}.Render(width, height)
}
items, selected, first := state.Items, state.Selected, state.First
n := items.Len()
allLines := []ui.Text{}
hasCropped := firstCrop > 0
var i, selectFrom, selectTo int
for i = first; i < n && len(allLines) < height; i++ {
item := items.Show(i)
lines := item.SplitByRune('\n')
if i == first {
lines = lines[firstCrop:]
}
if i == selected {
selectFrom, selectTo = len(allLines), len(allLines)+len(lines)
}
// TODO: Optionally, add underlines to the last line as a visual
// separator between adjacent entries.
if len(allLines)+len(lines) > height {
lines = lines[:len(allLines)+len(lines)-height]
hasCropped = true
}
allLines = append(allLines, lines...)
}
var rd Renderer = croppedLines{
lines: allLines, padding: w.Padding,
selectFrom: selectFrom, selectTo: selectTo, extendStyle: w.ExtendStyle}
if first > 0 || i < n || hasCropped {
rd = VScrollbarContainer{
Content: rd,
Scrollbar: VScrollbar{Total: n, Low: first, High: i},
}
}
return rd.Render(width, height)
}
type croppedLines struct {
lines []ui.Text
padding int
selectFrom int
selectTo int
extendStyle bool
}
func (c croppedLines) Render(width, height int) *term.Buffer {
bb := term.NewBufferBuilder(width)
leftSpacing := ui.T(strings.Repeat(" ", c.padding))
rightSpacing := ui.T(strings.Repeat(" ", width-c.padding))
for i, line := range c.lines {
if i > 0 {
bb.Newline()
}
selected := c.selectFrom <= i && i < c.selectTo
extendStyle := c.extendStyle && len(line) > 0
left := leftSpacing.Clone()
if extendStyle && len(left) > 0 {
left[0].Style = line[0].Style
}
acc := ui.Concat(left, line.TrimWcwidth(width-2*c.padding))
if extendStyle || selected {
right := rightSpacing.Clone()
if extendStyle {
right[0].Style = line[len(line)-1].Style
}
acc = ui.Concat(acc, right).TrimWcwidth(width)
}
if selected {
acc = ui.StyleText(acc, stylingForSelected)
}
bb.WriteStyled(acc)
}
return bb.Buffer()
}
func (w *listBox) Handle(event term.Event) bool {
if w.Bindings.Handle(w, event) {
return true
}
switch event {
case term.K(ui.Up):
w.Select(Prev)
return true
case term.K(ui.Down):
w.Select(Next)
return true
case term.K(ui.Enter):
w.Accept()
return true
}
return false
}
func (w *listBox) CopyState() ListBoxState {
w.StateMutex.RLock()
defer w.StateMutex.RUnlock()
return w.State
}
func (w *listBox) Reset(it Items, selected int) {
w.mutate(func(s *ListBoxState) { *s = ListBoxState{Items: it, Selected: selected} })
if 0 <= selected && selected < it.Len() {
w.OnSelect(it, selected)
}
}
func (w *listBox) Select(f func(ListBoxState) int) {
var it Items
var oldSelected, selected int
w.mutate(func(s *ListBoxState) {
oldSelected, it = s.Selected, s.Items
selected = f(*s)
s.Selected = selected
})
if selected != oldSelected && 0 <= selected && selected < it.Len() {
w.OnSelect(it, selected)
}
}
// Prev moves the selection to the previous item, or does nothing if the
// first item is currently selected. It is a suitable as an argument to
// [ListBox.Select].
func Prev(s ListBoxState) int {
return fixIndex(s.Selected-1, s.Items.Len())
}
// PrevPage moves the selection to the item one page before. It is only
// meaningful in vertical layout and suitable as an argument to
// [ListBox.Select].
//
// TODO(xiaq): This does not correctly with multi-line items.
func PrevPage(s ListBoxState) int {
return fixIndex(s.Selected-s.ContentHeight, s.Items.Len())
}
// Next moves the selection to the previous item, or does nothing if the
// last item is currently selected. It is a suitable as an argument to
// [ListBox.Select].
func Next(s ListBoxState) int {
return fixIndex(s.Selected+1, s.Items.Len())
}
// NextPage moves the selection to the item one page after. It is only
// meaningful in vertical layout and suitable as an argument to
// [ListBox.Select].
//
// TODO(xiaq): This does not correctly with multi-line items.
func NextPage(s ListBoxState) int {
return fixIndex(s.Selected+s.ContentHeight, s.Items.Len())
}
// PrevWrap moves the selection to the previous item, or to the last item if
// the first item is currently selected. It is a suitable as an argument to
// [ListBox.Select].
func PrevWrap(s ListBoxState) int {
selected, n := s.Selected, s.Items.Len()
switch {
case selected >= n:
return n - 1
case selected <= 0:
return n - 1
default:
return selected - 1
}
}
// NextWrap moves the selection to the previous item, or to the first item
// if the last item is currently selected. It is a suitable as an argument to
// [ListBox.Select].
func NextWrap(s ListBoxState) int {
selected, n := s.Selected, s.Items.Len()
switch {
case selected >= n-1:
return 0
case selected < 0:
return 0
default:
return selected + 1
}
}
// Left moves the selection to the item to the left. It is only meaningful in
// horizontal layout and suitable as an argument to [ListBox.Select].
func Left(s ListBoxState) int {
return horizontal(s.Selected, s.Items.Len(), -s.ContentHeight)
}
// Right moves the selection to the item to the right. It is only meaningful in
// horizontal layout and suitable as an argument to [ListBox.Select].
func Right(s ListBoxState) int {
return horizontal(s.Selected, s.Items.Len(), s.ContentHeight)
}
func horizontal(selected, n, d int) int {
selected = fixIndex(selected, n)
newSelected := selected + d
if newSelected < 0 || newSelected >= n {
return selected
}
return newSelected
}
func fixIndex(i, n int) int {
switch {
case i < 0:
return 0
case i >= n:
return n - 1
default:
return i
}
}
func (w *listBox) Accept() {
state := w.CopyState()
if 0 <= state.Selected && state.Selected < state.Items.Len() {
w.OnAccept(state.Items, state.Selected)
}
}
func (w *listBox) mutate(f func(s *ListBoxState)) {
w.StateMutex.Lock()
defer w.StateMutex.Unlock()
f(&w.State)
}
elvish-0.20.1/pkg/cli/tk/listbox_state.go 0000664 0000000 0000000 00000002331 14570151573 0020254 0 ustar 00root root 0000000 0000000 package tk
import (
"fmt"
"src.elv.sh/pkg/ui"
)
// ListBoxState keeps the mutable state ListBox.
type ListBoxState struct {
Items Items
Selected int
// The first element to show. Used when rendering and adjusted accordingly
// when the terminal size changes or the user has scrolled.
First int
// Height of the listbox, excluding horizontal scrollbar when using the
// horizontal layout. Stored in the state for commands to move the cursor by
// page (for vertical layout) or column (for horizontal layout).
ContentHeight int
}
// Items is an interface for accessing multiple items.
type Items interface {
// Show renders the item at the given zero-based index.
Show(i int) ui.Text
// Len returns the number of items.
Len() int
}
// TestItems is an implementation of Items useful for testing.
type TestItems struct {
Prefix string
Style ui.Styling
NItems int
}
// Show returns a plain text consisting of the prefix and i. If the prefix is
// empty, it defaults to "item ".
func (it TestItems) Show(i int) ui.Text {
prefix := it.Prefix
if prefix == "" {
prefix = "item "
}
return ui.T(fmt.Sprintf("%s%d", prefix, i), it.Style)
}
// Len returns it.NItems.
func (it TestItems) Len() int {
return it.NItems
}
elvish-0.20.1/pkg/cli/tk/listbox_test.go 0000664 0000000 0000000 00000033107 14570151573 0020120 0 ustar 00root root 0000000 0000000 package tk
import (
"testing"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/ui"
)
var listBoxRenderVerticalTests = []renderTest{
{
Name: "placeholder when Items is nil",
Given: NewListBox(ListBoxSpec{Placeholder: ui.T("nothing")}),
Width: 10, Height: 3,
Want: bb(10).Write("nothing"),
},
{
Name: "placeholder when NItems is 0",
Given: NewListBox(ListBoxSpec{
Placeholder: ui.T("nothing"),
State: ListBoxState{Items: TestItems{}}}),
Width: 10, Height: 3,
Want: bb(10).Write("nothing"),
},
{
Name: "all items when there is enough height",
Given: NewListBox(ListBoxSpec{State: ListBoxState{Items: TestItems{NItems: 2}, Selected: 0}}),
Width: 10, Height: 3,
Want: bb(10).
Write("item 0 ", ui.Inverse).
Newline().Write("item 1"),
},
{
Name: "long lines cropped",
Given: NewListBox(ListBoxSpec{State: ListBoxState{Items: TestItems{NItems: 2}, Selected: 0}}),
Width: 4, Height: 3,
Want: bb(4).
Write("item", ui.Inverse).
Newline().Write("item"),
},
{
Name: "scrollbar when not showing all items",
Given: NewListBox(ListBoxSpec{State: ListBoxState{Items: TestItems{NItems: 4}, Selected: 0}}),
Width: 10, Height: 2,
Want: bb(10).
Write("item 0 ", ui.Inverse).
Write(" ", ui.Inverse, ui.FgMagenta).
Newline().Write("item 1 ").
Write("│", ui.FgMagenta),
},
{
Name: "scrollbar when not showing last item in full",
Given: NewListBox(ListBoxSpec{
State: ListBoxState{
Items: TestItems{Prefix: "item\n", NItems: 2}, Selected: 0}}),
Width: 10, Height: 3,
Want: bb(10).
Write("item ", ui.Inverse).
Write(" ", ui.Inverse, ui.FgMagenta).
Newline().Write("0 ", ui.Inverse).
Write(" ", ui.Inverse, ui.FgMagenta).
Newline().Write("item ").
Write(" ", ui.Inverse, ui.FgMagenta),
},
{
Name: "scrollbar when not showing only item in full",
Given: NewListBox(ListBoxSpec{
State: ListBoxState{
Items: TestItems{Prefix: "item\n", NItems: 1}, Selected: 0}}),
Width: 10, Height: 1,
Want: bb(10).
Write("item ", ui.Inverse).
Write(" ", ui.Inverse, ui.FgMagenta),
},
{
Name: "padding",
Given: NewListBox(
ListBoxSpec{
Padding: 1,
State: ListBoxState{
Items: TestItems{Prefix: "item\n", NItems: 2}, Selected: 0}}),
Width: 4, Height: 4,
Want: bb(4).
Write(" it ", ui.Inverse).Newline().
Write(" 0 ", ui.Inverse).Newline().
Write(" it").Newline().
Write(" 1").Buffer(),
},
{
Name: "not extending style",
Given: NewListBox(ListBoxSpec{
Padding: 1,
State: ListBoxState{
Items: TestItems{
Prefix: "x", NItems: 2,
Style: ui.Stylings(ui.FgBlue, ui.BgGreen)}}}),
Width: 6, Height: 2,
Want: bb(6).
Write(" ", ui.Inverse).
Write("x0", ui.FgBlue, ui.BgGreen, ui.Inverse).
Write(" ", ui.Inverse).
Newline().
Write(" ").
Write("x1", ui.FgBlue, ui.BgGreen).
Buffer(),
},
{
Name: "extending style",
Given: NewListBox(ListBoxSpec{
Padding: 1, ExtendStyle: true,
State: ListBoxState{Items: TestItems{
Prefix: "x", NItems: 2,
Style: ui.Stylings(ui.FgBlue, ui.BgGreen)}}}),
Width: 6, Height: 2,
Want: bb(6).
Write(" x0 ", ui.FgBlue, ui.BgGreen, ui.Inverse).
Newline().
Write(" x1 ", ui.FgBlue, ui.BgGreen).
Buffer(),
},
}
func TestListBox_Render_Vertical(t *testing.T) {
testRender(t, listBoxRenderVerticalTests)
}
func TestListBox_Render_Vertical_MutatesState(t *testing.T) {
// Calling Render alters the First field to reflect the first item rendered.
w := NewListBox(ListBoxSpec{
State: ListBoxState{Items: TestItems{NItems: 10}, Selected: 4, First: 0}})
// Items shown will be 3, 4, 5
w.Render(10, 3)
state := w.CopyState()
if first := state.First; first != 3 {
t.Errorf("State.First = %d, want 3", first)
}
if height := state.ContentHeight; height != 3 {
t.Errorf("State.Height = %d, want 3", height)
}
}
var listBoxRenderHorizontalTests = []renderTest{
{
Name: "placeholder when Items is nil",
Given: NewListBox(ListBoxSpec{Horizontal: true, Placeholder: ui.T("nothing")}),
Width: 10, Height: 3,
Want: bb(10).Write("nothing"),
},
{
Name: "placeholder when NItems is 0",
Given: NewListBox(ListBoxSpec{
Horizontal: true, Placeholder: ui.T("nothing"),
State: ListBoxState{Items: TestItems{}}}),
Width: 10, Height: 3,
Want: bb(10).Write("nothing"),
},
{
Name: "all items when there is enough space, using minimal height",
Given: NewListBox(ListBoxSpec{
Horizontal: true,
State: ListBoxState{Items: TestItems{NItems: 4}, Selected: 0}}),
Width: 14, Height: 3,
// Available height is 3, but only need 2 lines.
Want: bb(14).
Write("item 0", ui.Inverse).
Write(" ").
Write("item 2").
Newline().Write("item 1 item 3"),
},
{
Name: "padding",
Given: NewListBox(ListBoxSpec{
Horizontal: true, Padding: 1,
State: ListBoxState{Items: TestItems{NItems: 4, Prefix: "x"}, Selected: 0}}),
Width: 14, Height: 3,
Want: bb(14).
Write(" x0 ", ui.Inverse).
Write(" ").
Write(" x2").
Newline().Write(" x1 x3"),
},
{
Name: "extending style",
Given: NewListBox(ListBoxSpec{
Horizontal: true, Padding: 1, ExtendStyle: true,
State: ListBoxState{Items: TestItems{
NItems: 2, Prefix: "x",
Style: ui.Stylings(ui.FgBlue, ui.BgGreen)}}}),
Width: 14, Height: 3,
Want: bb(14).
Write(" x0 ", ui.FgBlue, ui.BgGreen, ui.Inverse).
Write(" ").
Write(" x1 ", ui.FgBlue, ui.BgGreen),
},
{
Name: "long lines cropped, with full scrollbar",
Given: NewListBox(ListBoxSpec{
Horizontal: true,
State: ListBoxState{Items: TestItems{NItems: 2}, Selected: 0}}),
Width: 4, Height: 3,
Want: bb(4).
Write("item", ui.Inverse).
Newline().Write("item").
Newline().Write(" ", ui.FgMagenta, ui.Inverse),
},
{
Name: "scrollbar when not showing all items",
Given: NewListBox(ListBoxSpec{
Horizontal: true,
State: ListBoxState{Items: TestItems{NItems: 4}, Selected: 0}}),
Width: 6, Height: 3,
Want: bb(6).
Write("item 0", ui.Inverse).
Newline().Write("item 1").
Newline().
Write(" ", ui.Inverse, ui.FgMagenta).
Write("━━━", ui.FgMagenta),
},
{
Name: "scrollbar when not showing all items",
Given: NewListBox(ListBoxSpec{
Horizontal: true,
State: ListBoxState{Items: TestItems{NItems: 4}, Selected: 0}}),
Width: 10, Height: 3,
Want: bb(10).
Write("item 0", ui.Inverse).Write(" it").
Newline().Write("item 1 it").
Newline().
Write(" ", ui.Inverse, ui.FgMagenta),
},
{
Name: "not showing scrollbar with height = 1",
Given: NewListBox(ListBoxSpec{
Horizontal: true,
State: ListBoxState{Items: TestItems{NItems: 4}, Selected: 0}}),
Width: 10, Height: 1,
Want: bb(10).
Write("item 0", ui.Inverse).Write(" it"),
},
}
func TestListBox_Render_Horizontal(t *testing.T) {
testRender(t, listBoxRenderHorizontalTests)
}
func TestListBox_Render_Horizontal_MutatesState(t *testing.T) {
// Calling Render alters the First field to reflect the first item rendered.
w := NewListBox(ListBoxSpec{
Horizontal: true,
State: ListBoxState{
Items: TestItems{Prefix: "x", NItems: 10}, Selected: 4, First: 0}})
// Only a single column of 3 items shown: x3-x5
w.Render(2, 4)
state := w.CopyState()
if first := state.First; first != 3 {
t.Errorf("State.First = %d, want 3", first)
}
if height := state.ContentHeight; height != 3 {
t.Errorf("State.Height = %d, want 3", height)
}
}
var listBoxHandleTests = []handleTest{
{
Name: "up moving selection up",
Given: NewListBox(ListBoxSpec{State: ListBoxState{Items: TestItems{NItems: 10}, Selected: 1}}),
Event: term.K(ui.Up),
WantNewState: ListBoxState{Items: TestItems{NItems: 10}, Selected: 0},
},
{
Name: "up stopping at 0",
Given: NewListBox(ListBoxSpec{State: ListBoxState{Items: TestItems{NItems: 10}, Selected: 0}}),
Event: term.K(ui.Up),
WantNewState: ListBoxState{Items: TestItems{NItems: 10}, Selected: 0},
},
{
Name: "up moving to last item when selecting after boundary",
Given: NewListBox(ListBoxSpec{State: ListBoxState{Items: TestItems{NItems: 10}, Selected: 11}}),
Event: term.K(ui.Up),
WantNewState: ListBoxState{Items: TestItems{NItems: 10}, Selected: 9},
},
{
Name: "down moving selection down",
Given: NewListBox(ListBoxSpec{State: ListBoxState{Items: TestItems{NItems: 10}, Selected: 1}}),
Event: term.K(ui.Down),
WantNewState: ListBoxState{Items: TestItems{NItems: 10}, Selected: 2},
},
{
Name: "down stopping at n-1",
Given: NewListBox(ListBoxSpec{State: ListBoxState{Items: TestItems{NItems: 10}, Selected: 9}}),
Event: term.K(ui.Down),
WantNewState: ListBoxState{Items: TestItems{NItems: 10}, Selected: 9},
},
{
Name: "down moving to first item when selecting before boundary",
Given: NewListBox(ListBoxSpec{State: ListBoxState{Items: TestItems{NItems: 10}, Selected: -2}}),
Event: term.K(ui.Down),
WantNewState: ListBoxState{Items: TestItems{NItems: 10}, Selected: 0},
},
{
Name: "enter triggering default no-op accept",
Given: NewListBox(ListBoxSpec{State: ListBoxState{Items: TestItems{NItems: 10}, Selected: 5}}),
Event: term.K(ui.Enter),
WantNewState: ListBoxState{Items: TestItems{NItems: 10}, Selected: 5},
},
{
Name: "other keys not handled",
Given: NewListBox(ListBoxSpec{State: ListBoxState{Items: TestItems{NItems: 10}, Selected: 5}}),
Event: term.K('a'),
WantUnhandled: true,
},
{
Name: "bindings",
Given: NewListBox(ListBoxSpec{
State: ListBoxState{Items: TestItems{NItems: 10}, Selected: 5},
Bindings: MapBindings{
term.K('a'): func(w Widget) { w.(*listBox).State.Selected = 0 },
},
}),
Event: term.K('a'),
WantNewState: ListBoxState{Items: TestItems{NItems: 10}, Selected: 0},
},
}
func TestListBox_Handle(t *testing.T) {
testHandle(t, listBoxHandleTests)
}
func TestListBox_Handle_EnterEmitsAccept(t *testing.T) {
var acceptedItems Items
var acceptedIndex int
w := NewListBox(ListBoxSpec{
OnAccept: func(it Items, i int) {
acceptedItems = it
acceptedIndex = i
},
State: ListBoxState{Items: TestItems{NItems: 10}, Selected: 5}})
w.Handle(term.K(ui.Enter))
if acceptedItems != (TestItems{NItems: 10}) {
t.Errorf("OnAccept not passed current Items")
}
if acceptedIndex != 5 {
t.Errorf("OnAccept not passed current selected index")
}
}
func TestListBox_Select_ChangeState(t *testing.T) {
// number of items = 10, height = 3
var tests = []struct {
name string
before int
f func(ListBoxState) int
after int
}{
{"Next from -1", -1, Next, 0},
{"Next from 0", 0, Next, 1},
{"Next from 9", 9, Next, 9},
{"Next from 10", 10, Next, 9},
{"NextWrap from -1", -1, NextWrap, 0},
{"NextWrap from 0", 0, NextWrap, 1},
{"NextWrap from 9", 9, NextWrap, 0},
{"NextWrap from 10", 10, NextWrap, 0},
{"NextPage from -1", -1, NextPage, 2},
{"NextPage from 0", 0, NextPage, 3},
{"NextPage from 9", 9, NextPage, 9},
{"NextPage from 10", 10, NextPage, 9},
{"Prev from -1", -1, Prev, 0},
{"Prev from 0", 0, Prev, 0},
{"Prev from 9", 9, Prev, 8},
{"Prev from 10", 10, Prev, 9},
{"PrevWrap from -1", -1, PrevWrap, 9},
{"PrevWrap from 0", 0, PrevWrap, 9},
{"PrevWrap from 9", 9, PrevWrap, 8},
{"PrevWrap from 10", 10, PrevWrap, 9},
{"PrevPage from -1", -1, PrevPage, 0},
{"PrevPage from 0", 0, PrevPage, 0},
{"PrevPage from 9", 9, PrevPage, 6},
{"PrevPage from 10", 10, PrevPage, 7},
{"Left from -1", -1, Left, 0},
{"Left from 0", 0, Left, 0},
{"Left from 9", 9, Left, 6},
{"Left from 10", 10, Left, 6},
{"Right from -1", -1, Right, 3},
{"Right from 0", 0, Right, 3},
{"Right from 9", 9, Right, 9},
{"Right from 10", 10, Right, 9},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
w := NewListBox(ListBoxSpec{
State: ListBoxState{
Items: TestItems{NItems: 10}, ContentHeight: 3,
Selected: test.before}})
w.Select(test.f)
if selected := w.CopyState().Selected; selected != test.after {
t.Errorf("selected = %d, want %d", selected, test.after)
}
})
}
}
func TestListBox_Select_CallOnSelect(t *testing.T) {
it := TestItems{NItems: 10}
gotItemsCh := make(chan Items, 10)
gotSelectedCh := make(chan int, 10)
w := NewListBox(ListBoxSpec{
OnSelect: func(it Items, i int) {
gotItemsCh <- it
gotSelectedCh <- i
},
State: ListBoxState{Items: it, Selected: 5}})
verifyOnSelect := func(wantSelected int) {
if gotItems := <-gotItemsCh; gotItems != it {
t.Errorf("Got it = %v, want %v", gotItems, it)
}
if gotSelected := <-gotSelectedCh; gotSelected != wantSelected {
t.Errorf("Got selected = %v, want %v", gotSelected, wantSelected)
}
}
// Test that OnSelect is called during initialization.
verifyOnSelect(5)
// Test that OnSelect is called when changing selection.
w.Select(Next)
verifyOnSelect(6)
// Test that OnSelect is not called when index is invalid. Instead of
// waiting a fixed time to make sure that nothing is sent in the channel, we
// immediately does another Select with a valid index, and verify that only
// the valid index is sent.
w.Select(func(ListBoxState) int { return -1 })
w.Select(func(ListBoxState) int { return 0 })
verifyOnSelect(0)
}
func TestListBox_Accept_IndexCheck(t *testing.T) {
tests := []struct {
name string
nItems int
selected int
shouldAccept bool
}{
{"index in range", 1, 0, true},
{"index exceeds left boundary", 1, -1, false},
{"index exceeds right boundary", 0, 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := NewListBox(ListBoxSpec{
OnAccept: func(it Items, i int) {
if !tt.shouldAccept {
t.Error("should not accept this state")
}
},
State: ListBoxState{
Items: TestItems{NItems: tt.nItems},
Selected: tt.selected,
},
})
w.Accept()
})
}
}
elvish-0.20.1/pkg/cli/tk/listbox_window.go 0000664 0000000 0000000 00000012014 14570151573 0020442 0 ustar 00root root 0000000 0000000 package tk
import "src.elv.sh/pkg/wcwidth"
// The number of lines the listing mode keeps between the current selected item
// and the top and bottom edges of the window, unless the available height is
// too small or if the selected item is near the top or bottom of the list.
var respectDistance = 2
// Determines the index of the first item to show in vertical mode.
//
// This function does not return the full window, but just the first item to
// show, and how many initial lines to crop. The window determined by this
// algorithm has the following properties:
//
// - It always includes the selected item.
//
// - The combined height of all the entries in the window is equal to
// min(height, combined height of all entries).
//
// - There are at least respectDistance rows above the first row of the selected
// item, as well as that many rows below the last row of the selected item,
// unless the height is too small.
//
// - Among all values satisfying the above conditions, the value of first is
// the one closest to lastFirst.
func getVerticalWindow(state ListBoxState, height int) (first, crop int) {
items, selected, lastFirst := state.Items, state.Selected, state.First
n := items.Len()
if selected < 0 {
selected = 0
} else if selected >= n {
selected = n - 1
}
selectedHeight := items.Show(selected).CountLines()
if height <= selectedHeight {
// The height is not big enough (or just big enough) to fit the selected
// item. Fit as much as the selected item as we can.
return selected, 0
}
// Determine the minimum amount of space required for the downward direction.
budget := height - selectedHeight
var needDown int
if budget >= 2*respectDistance {
// If we can afford maintaining the respect distance on both sides, then
// the minimum amount of space required is the respect distance.
needDown = respectDistance
} else {
// Otherwise we split the available space by half. The downward (no pun
// intended) rounding here is an arbitrary choice.
needDown = budget / 2
}
// Calculate how much of the budget the downward direction can use. This is
// used to 1) potentially shrink needDown 2) decide how much to expand
// upward later.
useDown := 0
for i := selected + 1; i < n; i++ {
useDown += items.Show(i).CountLines()
if useDown >= budget {
break
}
}
if needDown > useDown {
// We reached the last item without using all of needDown. That means we
// don't need so much in the downward direction.
needDown = useDown
}
// The maximum amount of space we can use in the upward direction is the
// entire budget minus the minimum amount of space we need in the downward
// direction.
budgetUp := budget - needDown
useUp := 0
// Extend upwards until any of the following becomes true:
//
// * We have exhausted budgetUp;
//
// * We have reached item 0;
//
// * We have reached or passed lastFirst, satisfied the upward respect
// distance, and will be able to use up the entire budget when expanding
// downwards later.
for i := selected - 1; i >= 0; i-- {
useUp += items.Show(i).CountLines()
if useUp >= budgetUp {
return i, useUp - budgetUp
}
if i <= lastFirst && useUp >= respectDistance && useUp+useDown >= budget {
return i, 0
}
}
return 0, 0
}
// Determines the window to show in horizontal. Returns the first item to show,
// the height of each column, and whether a scrollbar may be shown.
func getHorizontalWindow(state ListBoxState, padding, width, height int) (int, int, bool) {
items := state.Items
n := items.Len()
// Lower bound of number of items that can fit in a row.
perRow := (width + listBoxColGap) / (maxWidth(items, padding, 0, n) + listBoxColGap)
if perRow == 0 {
// We trim items that are too wide, so there is at least one item per row.
perRow = 1
}
if height*perRow >= n {
// All items can fit.
return 0, (n + perRow - 1) / perRow, false
}
// At this point, assume that we'll have to use the entire available height
// and show a scrollbar, unless height is 1, in which case we'd rather use the
// one line to show some actual content and give up the scrollbar.
//
// This is rather pessimistic, but until an efficient
// algorithm that generates a more optimal layout emerges we'll use this
// simple one.
scrollbar := false
if height > 1 {
scrollbar = true
height--
}
selected, lastFirst := state.Selected, state.First
// Start with the column containing the selected item, move left until
// either the width is exhausted, or lastFirst has been reached.
first := selected / height * height
usedWidth := maxWidth(items, padding, first, first+height)
for ; first > lastFirst; first -= height {
usedWidth += maxWidth(items, padding, first-height, first) + listBoxColGap
if usedWidth > width {
break
}
}
return first, height, scrollbar
}
func maxWidth(items Items, padding, low, high int) int {
n := items.Len()
width := 0
for i := low; i < high && i < n; i++ {
w := 0
for _, seg := range items.Show(i) {
w += wcwidth.Of(seg.Text)
}
if width < w {
width = w
}
}
return width + 2*padding
}
elvish-0.20.1/pkg/cli/tk/listbox_window_test.go 0000664 0000000 0000000 00000010544 14570151573 0021507 0 ustar 00root root 0000000 0000000 package tk
import (
"testing"
"src.elv.sh/pkg/tt"
)
func TestGetVerticalWindow(t *testing.T) {
tt.Test(t, getVerticalWindow,
// selected = 0: always show a widow starting from 0, regardless of
// the value of oldFirst
Args(ListBoxState{Items: TestItems{NItems: 10}, Selected: 0, First: 0}, 6).Rets(0, 0),
Args(ListBoxState{Items: TestItems{NItems: 10}, Selected: 0, First: 1}, 6).Rets(0, 0),
// selected < 0 is treated as if = 0.
Args(ListBoxState{Items: TestItems{NItems: 10}, Selected: -1, First: 0}, 6).Rets(0, 0),
// selected = n-1: always show a window ending at n-1, regardless of the
// value of oldFirst
Args(ListBoxState{Items: TestItems{NItems: 10}, Selected: 9, First: 0}, 6).Rets(4, 0),
Args(ListBoxState{Items: TestItems{NItems: 10}, Selected: 9, First: 8}, 6).Rets(4, 0),
// selected >= n is treated as if = n-1.
Args(ListBoxState{Items: TestItems{NItems: 10}, Selected: 10, First: 0}, 6).Rets(4, 0),
// selected = 3, oldFirst = 2 (likely because previous selected = 4).
// Adjust first -> 1 to satisfy the upward respect distance of 2.
Args(ListBoxState{Items: TestItems{NItems: 10}, Selected: 3, First: 2}, 6).Rets(1, 0),
// selected = 6, oldFirst = 2 (likely because previous selected = 7).
// Adjust first -> 3 to satisfy the downward respect distance of 2.
Args(ListBoxState{Items: TestItems{NItems: 10}, Selected: 6, First: 2}, 6).Rets(3, 0),
// There is not enough budget to achieve respect distance on both sides.
// Split the budget in half.
Args(ListBoxState{Items: TestItems{NItems: 10}, Selected: 3, First: 1}, 3).Rets(2, 0),
Args(ListBoxState{Items: TestItems{NItems: 10}, Selected: 3, First: 0}, 3).Rets(2, 0),
// There is just enough distance to fit the selected item. Only show the
// selected item.
Args(ListBoxState{Items: TestItems{NItems: 10}, Selected: 2, First: 0}, 1).Rets(2, 0),
)
}
func TestGetHorizontalWindow(t *testing.T) {
tt.Test(t, getHorizontalWindow,
// All items fit in a single column. Item width is 6 ("item 0").
Args(ListBoxState{Items: TestItems{NItems: 10}, Selected: 4, First: 0}, 0, 6, 10).Rets(0, 10),
// All items fit in multiple columns. Item width is 2 ("x0").
Args(ListBoxState{Items: TestItems{Prefix: "x", NItems: 10}, Selected: 4, First: 0}, 0, 6, 5).Rets(0, 5),
// All items cannot fit, selected = 0; show a window from 0. Height
// reduced to make room for scrollbar.
Args(ListBoxState{Items: TestItems{Prefix: "x", NItems: 11}, Selected: 0, First: 0}, 0, 6, 5).Rets(0, 4),
// All items cannot fit. Columns are 0-3, 4-7, 8-10 (height reduced from
// 5 to 4 for scrollbar). Selecting last item, and showing last two
// columns; height reduced to make room for scrollbar.
Args(ListBoxState{Items: TestItems{Prefix: "x", NItems: 11}, Selected: 10, First: 0}, 0, 7, 5).Rets(4, 4),
// Items are wider than terminal, and there is a single column. Show
// them all.
Args(ListBoxState{Items: TestItems{Prefix: "long prefix", NItems: 10}, Selected: 9, First: 0}, 0,
6, 10).Rets(0, 10),
// Items are wider than terminal, and there are multiple columns. Treat
// them as if each column occupies a full width. Columns are 0-4, 5-9.
Args(ListBoxState{Items: TestItems{Prefix: "long prefix", NItems: 10}, Selected: 9, First: 0}, 0,
6, 6).Rets(5, 5),
// The following cases only differ in State.First and shows that the
// algorithm respects it. In all cases, the columns are 0-4, 5-9,
// 10-14, 15-19, item 10 is selected, and the terminal can fit 2 columns.
// First = 0. Try to reach as far as possible to that, ending up showing
// columns 5-9 and 10-14.
Args(ListBoxState{Items: TestItems{Prefix: "x", NItems: 20}, Selected: 10, First: 0}, 0, 8, 6).Rets(5, 5),
// First = 2. Ditto.
Args(ListBoxState{Items: TestItems{Prefix: "x", NItems: 20}, Selected: 10, First: 2}, 0, 8, 6).Rets(5, 5),
// First = 5. Show columns 5-9 and 10-14.
Args(ListBoxState{Items: TestItems{Prefix: "x", NItems: 20}, Selected: 10, First: 5}, 0, 8, 6).Rets(5, 5),
// First = 7. Ditto.
Args(ListBoxState{Items: TestItems{Prefix: "x", NItems: 20}, Selected: 10, First: 7}, 0, 8, 6).Rets(5, 5),
// First = 10. No need to any columns to the left.
Args(ListBoxState{Items: TestItems{Prefix: "x", NItems: 20}, Selected: 10, First: 10}, 0, 8, 6).Rets(10, 5),
// First = 12. Ditto.
Args(ListBoxState{Items: TestItems{Prefix: "x", NItems: 20}, Selected: 10, First: 12}, 0, 8, 6).Rets(10, 5),
)
}
elvish-0.20.1/pkg/cli/tk/scrollbar.go 0000664 0000000 0000000 00000004053 14570151573 0017356 0 ustar 00root root 0000000 0000000 package tk
import (
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/ui"
)
// VScrollbarContainer is a Renderer consisting of content and a vertical
// scrollbar on the right.
type VScrollbarContainer struct {
Content Renderer
Scrollbar VScrollbar
}
func (v VScrollbarContainer) Render(width, height int) *term.Buffer {
buf := v.Content.Render(width-1, height)
buf.ExtendRight(v.Scrollbar.Render(1, height))
return buf
}
// VScrollbar is a Renderer for a vertical scrollbar.
type VScrollbar struct {
Total int
Low int
High int
}
var (
vscrollbarThumb = ui.T(" ", ui.FgMagenta, ui.Inverse)
vscrollbarTrough = ui.T("│", ui.FgMagenta)
)
func (v VScrollbar) Render(width, height int) *term.Buffer {
posLow, posHigh := findScrollInterval(v.Total, v.Low, v.High, height)
bb := term.NewBufferBuilder(1)
for i := 0; i < height; i++ {
if i > 0 {
bb.Newline()
}
if posLow <= i && i < posHigh {
bb.WriteStyled(vscrollbarThumb)
} else {
bb.WriteStyled(vscrollbarTrough)
}
}
return bb.Buffer()
}
// HScrollbar is a Renderer for a horizontal scrollbar.
type HScrollbar struct {
Total int
Low int
High int
}
var (
hscrollbarThumb = ui.T(" ", ui.FgMagenta, ui.Inverse)
hscrollbarTrough = ui.T("━", ui.FgMagenta)
)
func (h HScrollbar) Render(width, height int) *term.Buffer {
posLow, posHigh := findScrollInterval(h.Total, h.Low, h.High, width)
bb := term.NewBufferBuilder(width)
for i := 0; i < width; i++ {
if posLow <= i && i < posHigh {
bb.WriteStyled(hscrollbarThumb)
} else {
bb.WriteStyled(hscrollbarTrough)
}
}
return bb.Buffer()
}
func findScrollInterval(n, low, high, height int) (int, int) {
f := func(i int) int {
return int(float64(i)/float64(n)*float64(height) + 0.5)
}
scrollLow := f(low)
// We use the following instead of f(high), so that the size of the
// scrollbar remains the same as long as the window size remains the same.
scrollHigh := scrollLow + f(high-low)
if scrollLow == scrollHigh {
if scrollHigh == height {
scrollLow--
} else {
scrollHigh++
}
}
return scrollLow, scrollHigh
}
elvish-0.20.1/pkg/cli/tk/textview.go 0000664 0000000 0000000 00000006210 14570151573 0017247 0 ustar 00root root 0000000 0000000 package tk
import (
"sync"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/ui"
"src.elv.sh/pkg/wcwidth"
)
// TextView is a Widget for displaying text, with support for vertical
// scrolling.
//
// NOTE: This widget now always crops long lines. In future it should support
// wrapping and horizontal scrolling.
type TextView interface {
Widget
// ScrollBy scrolls the widget by the given delta. Positive values scroll
// down, and negative values scroll up.
ScrollBy(delta int)
// MutateState mutates the state.
MutateState(f func(*TextViewState))
// CopyState returns a copy of the State.
CopyState() TextViewState
}
// TextViewSpec specifies the configuration and initial state for a Widget.
type TextViewSpec struct {
// Key bindings.
Bindings Bindings
// If true, a vertical scrollbar will be shown when there are more lines
// that can be displayed, and the widget responds to Up and Down keys.
Scrollable bool
// State. Specifies the initial state if used in New.
State TextViewState
}
// TextViewState keeps mutable state of TextView.
type TextViewState struct {
Lines []string
First int
}
type textView struct {
// Mutex for synchronizing access to the state.
StateMutex sync.RWMutex
TextViewSpec
}
// NewTextView builds a TextView from the given spec.
func NewTextView(spec TextViewSpec) TextView {
if spec.Bindings == nil {
spec.Bindings = DummyBindings{}
}
return &textView{TextViewSpec: spec}
}
func (w *textView) Render(width, height int) *term.Buffer {
lines, first := w.getStateForRender(height)
needScrollbar := w.Scrollable && (first > 0 || first+height < len(lines))
textWidth := width
if needScrollbar {
textWidth--
}
bb := term.NewBufferBuilder(textWidth)
for i := first; i < first+height && i < len(lines); i++ {
if i > first {
bb.Newline()
}
bb.Write(wcwidth.Trim(lines[i], textWidth))
}
buf := bb.Buffer()
if needScrollbar {
scrollbar := VScrollbar{
Total: len(lines), Low: first, High: first + height}
buf.ExtendRight(scrollbar.Render(1, height))
}
return buf
}
func (w *textView) MaxHeight(width, height int) int {
return len(w.CopyState().Lines)
}
func (w *textView) getStateForRender(height int) (lines []string, first int) {
w.MutateState(func(s *TextViewState) {
if s.First > len(s.Lines)-height && len(s.Lines)-height >= 0 {
s.First = len(s.Lines) - height
}
lines, first = s.Lines, s.First
})
return
}
func (w *textView) Handle(event term.Event) bool {
if w.Bindings.Handle(w, event) {
return true
}
if w.Scrollable {
switch event {
case term.K(ui.Up):
w.ScrollBy(-1)
return true
case term.K(ui.Down):
w.ScrollBy(1)
return true
}
}
return false
}
func (w *textView) ScrollBy(delta int) {
w.MutateState(func(s *TextViewState) {
s.First += delta
if s.First < 0 {
s.First = 0
}
if s.First >= len(s.Lines) {
s.First = len(s.Lines) - 1
}
})
}
func (w *textView) MutateState(f func(*TextViewState)) {
w.StateMutex.Lock()
defer w.StateMutex.Unlock()
f(&w.State)
}
// CopyState returns a copy of the State while r-locking the StateMutex.
func (w *textView) CopyState() TextViewState {
w.StateMutex.RLock()
defer w.StateMutex.RUnlock()
return w.State
}
elvish-0.20.1/pkg/cli/tk/textview_test.go 0000664 0000000 0000000 00000007043 14570151573 0020313 0 ustar 00root root 0000000 0000000 package tk
import (
"reflect"
"testing"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/ui"
)
var textViewRenderTests = []renderTest{
{
Name: "text fits entirely",
Given: NewTextView(TextViewSpec{State: TextViewState{
Lines: []string{"line 1", "line 2", "line 3"}}}),
Width: 10, Height: 4,
Want: bb(10).
Write("line 1").Newline().
Write("line 2").Newline().
Write("line 3").Buffer(),
},
{
Name: "text cropped horizontally",
Given: NewTextView(TextViewSpec{State: TextViewState{
Lines: []string{"a very long line"}}}),
Width: 10, Height: 4,
Want: bb(10).
Write("a very lon").Buffer(),
},
{
Name: "text cropped vertically",
Given: NewTextView(TextViewSpec{State: TextViewState{
Lines: []string{"line 1", "line 2", "line 3"}}}),
Width: 10, Height: 2,
Want: bb(10).
Write("line 1").Newline().
Write("line 2").Buffer(),
},
{
Name: "text cropped vertically, with scrollbar",
Given: NewTextView(TextViewSpec{
Scrollable: true,
State: TextViewState{
Lines: []string{"line 1", "line 2", "line 3", "line 4"}}}),
Width: 10, Height: 2,
Want: bb(10).
Write("line 1 ").
Write(" ", ui.Inverse, ui.FgMagenta).Newline().
Write("line 2 ").
Write("│", ui.FgMagenta).Buffer(),
},
{
Name: "State.First adjusted to fit text",
Given: NewTextView(TextViewSpec{State: TextViewState{
First: 2,
Lines: []string{"line 1", "line 2", "line 3"}}}),
Width: 10, Height: 3,
Want: bb(10).
Write("line 1").Newline().
Write("line 2").Newline().
Write("line 3").Buffer(),
},
}
func TestTextView_Render(t *testing.T) {
testRender(t, textViewRenderTests)
}
var textViewHandleTests = []handleTest{
{
Name: "up doing nothing when not scrollable",
Given: NewTextView(TextViewSpec{
State: TextViewState{Lines: []string{"1", "2", "3", "4"}, First: 1}}),
Event: term.K(ui.Up),
WantUnhandled: true,
},
{
Name: "up moving window up when scrollable",
Given: NewTextView(TextViewSpec{
Scrollable: true,
State: TextViewState{Lines: []string{"1", "2", "3", "4"}, First: 1}}),
Event: term.K(ui.Up),
WantNewState: TextViewState{Lines: []string{"1", "2", "3", "4"}, First: 0},
},
{
Name: "up doing nothing when already at top",
Given: NewTextView(TextViewSpec{
Scrollable: true,
State: TextViewState{Lines: []string{"1", "2", "3", "4"}, First: 0}}),
Event: term.K(ui.Up),
WantNewState: TextViewState{Lines: []string{"1", "2", "3", "4"}, First: 0},
},
{
Name: "down moving window down when scrollable",
Given: NewTextView(TextViewSpec{
Scrollable: true,
State: TextViewState{Lines: []string{"1", "2", "3", "4"}, First: 1}}),
Event: term.K(ui.Down),
WantNewState: TextViewState{Lines: []string{"1", "2", "3", "4"}, First: 2},
},
{
Name: "down doing nothing when already at bottom",
Given: NewTextView(TextViewSpec{
Scrollable: true,
State: TextViewState{Lines: []string{"1", "2", "3", "4"}, First: 3}}),
Event: term.K(ui.Down),
WantNewState: TextViewState{Lines: []string{"1", "2", "3", "4"}, First: 3},
},
{
Name: "bindings",
Given: NewTextView(TextViewSpec{
Bindings: MapBindings{term.K('a'): func(Widget) {}}}),
Event: term.K('a'),
WantNewState: TextViewState{},
},
}
func TestTextView_Handle(t *testing.T) {
testHandle(t, textViewHandleTests)
}
func TestTextView_CopyState(t *testing.T) {
state := TextViewState{Lines: []string{"a", "b", "c"}, First: 1}
w := NewTextView(TextViewSpec{State: state})
copied := w.CopyState()
if !reflect.DeepEqual(copied, state) {
t.Errorf("Got copied state %v, want %v", copied, state)
}
}
elvish-0.20.1/pkg/cli/tk/utils_test.go 0000664 0000000 0000000 00000006475 14570151573 0017604 0 ustar 00root root 0000000 0000000 package tk
import (
"reflect"
"testing"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/ui"
)
// renderTest is a test case to be used in TestRenderer.
type renderTest struct {
Name string
Given Renderer
Width int
Height int
Want interface{ Buffer() *term.Buffer }
}
// testRender runs the given Renderer tests.
func testRender(t *testing.T, tests []renderTest) {
t.Helper()
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
t.Helper()
buf := test.Given.Render(test.Width, test.Height)
wantBuf := test.Want.Buffer()
if !reflect.DeepEqual(buf, wantBuf) {
t.Errorf("Buffer mismatch")
t.Logf("Got: %s", buf.TTYString())
t.Logf("Want: %s", wantBuf.TTYString())
}
})
}
}
// handleTest is a test case to be used in testHandle.
type handleTest struct {
Name string
Given Handler
Event term.Event
Events []term.Event
WantNewState any
WantUnhandled bool
}
// testHandle runs the given Handler tests.
func testHandle(t *testing.T, tests []handleTest) {
t.Helper()
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
t.Helper()
handler := test.Given
oldState := getState(handler)
defer setState(handler, oldState)
var handled bool
switch {
case test.Event != nil && test.Events != nil:
t.Fatal("Malformed test case: both Event and Events non-nil:",
test.Event, test.Events)
case test.Event == nil && test.Events == nil:
t.Fatal("Malformed test case: both Event and Events nil")
case test.Event != nil:
handled = handler.Handle(test.Event)
default: // test.Events != nil
for _, event := range test.Events {
handled = handler.Handle(event)
}
}
if handled != !test.WantUnhandled {
t.Errorf("Got handled %v, want %v", handled, !test.WantUnhandled)
}
if test.WantNewState != nil {
state := getState(test.Given)
if !reflect.DeepEqual(state, test.WantNewState) {
t.Errorf("Got state %v, want %v", state, test.WantNewState)
}
}
})
}
}
func getState(v any) any {
return reflectState(v).Interface()
}
func setState(v, state any) {
reflectState(v).Set(reflect.ValueOf(state))
}
func reflectState(v any) reflect.Value {
rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Ptr {
rv = reflect.Indirect(rv)
}
return rv.FieldByName("State")
}
// Test for the test utilities.
func TestTestRender(t *testing.T) {
testRender(t, []renderTest{
{
Name: "test",
Given: &testWidget{text: ui.T("test")},
Width: 10, Height: 10,
Want: term.NewBufferBuilder(10).Write("test"),
},
})
}
type testHandlerWithState struct {
State testHandlerState
}
type testHandlerState struct {
last term.Event
total int
}
func (h *testHandlerWithState) Handle(e term.Event) bool {
if e == term.K('x') {
return false
}
h.State.last = e
h.State.total++
return true
}
func TestTestHandle(t *testing.T) {
testHandle(t, []handleTest{
{
Name: "WantNewState",
Given: &testHandlerWithState{},
Event: term.K('a'),
WantNewState: testHandlerState{last: term.K('a'), total: 1},
},
{
Name: "Multiple events",
Given: &testHandlerWithState{},
Events: []term.Event{term.K('a'), term.K('b')},
WantNewState: testHandlerState{last: term.K('b'), total: 2},
},
{
Name: "WantUnhaneld",
Given: &testHandlerWithState{},
Event: term.K('x'),
WantUnhandled: true,
},
})
}
elvish-0.20.1/pkg/cli/tk/widget.go 0000664 0000000 0000000 00000003633 14570151573 0016661 0 ustar 00root root 0000000 0000000 // Package tk is the toolkit for the cli package.
//
// This package defines three basic interfaces - Renderer, Handler and Widget -
// and numerous implementations of these interfaces.
package tk
import (
"src.elv.sh/pkg/cli/term"
)
// Widget is the basic component of UI; it knows how to handle events and how to
// render itself.
type Widget interface {
Renderer
MaxHeighter
Handler
}
// Renderer wraps the Render method.
type Renderer interface {
// Render renders onto a region of bound width and height.
Render(width, height int) *term.Buffer
}
// MaxHeighter wraps the MaxHeight method.
type MaxHeighter interface {
// MaxHeight returns the maximum height needed when rendering onto a region
// of bound width and height. The returned value may be larger than the
// height argument.
MaxHeight(width, height int) int
}
// Handler wraps the Handle method.
type Handler interface {
// Try to handle a terminal event and returns whether the event has been
// handled.
Handle(event term.Event) bool
}
// Bindings is the interface for key bindings.
type Bindings interface {
Handle(Widget, term.Event) bool
}
// DummyBindings is a trivial Bindings implementation.
type DummyBindings struct{}
// Handle always returns false.
func (DummyBindings) Handle(w Widget, event term.Event) bool {
return false
}
// MapBindings is a map-backed Bindings implementation.
type MapBindings map[term.Event]func(Widget)
// Handle handles the event by calling the function corresponding to the event
// in the map. If there is no corresponding function, it returns false.
func (m MapBindings) Handle(w Widget, event term.Event) bool {
fn, ok := m[event]
if ok {
fn(w)
}
return ok
}
// FuncBindings is a function-based Bindings implementation.
type FuncBindings func(Widget, term.Event) bool
// Handle handles the event by calling the function.
func (f FuncBindings) Handle(w Widget, event term.Event) bool {
return f(w, event)
}
elvish-0.20.1/pkg/cli/tk/widget_test.go 0000664 0000000 0000000 00000004047 14570151573 0017720 0 ustar 00root root 0000000 0000000 package tk
import (
"testing"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/ui"
)
type testWidget struct {
// Text to render.
text ui.Text
// Which events to accept.
accepted []term.Event
// A record of events that have been handled.
handled []term.Event
}
func (w *testWidget) Render(width, height int) *term.Buffer {
buf := term.NewBufferBuilder(width).WriteStyled(w.text).Buffer()
buf.TrimToLines(0, height)
return buf
}
func (w *testWidget) Handle(e term.Event) bool {
for _, accept := range w.accepted {
if e == accept {
w.handled = append(w.handled, e)
return true
}
}
return false
}
func TestDummyBindings(t *testing.T) {
w := Empty{}
b := DummyBindings{}
for _, event := range []term.Event{term.K('a'), term.PasteSetting(true)} {
if b.Handle(w, event) {
t.Errorf("should not handle")
}
}
}
func TestMapBindings(t *testing.T) {
widgetCh := make(chan Widget, 1)
w := Empty{}
b := MapBindings{term.K('a'): func(w Widget) { widgetCh <- w }}
handled := b.Handle(w, term.K('a'))
if !handled {
t.Errorf("should handle")
}
if gotWidget := <-widgetCh; gotWidget != w {
t.Errorf("function called with widget %v, want %v", gotWidget, w)
}
handled = b.Handle(w, term.K('b'))
if handled {
t.Errorf("should not handle")
}
}
func TestFuncBindings(t *testing.T) {
widgetCh := make(chan Widget, 1)
eventCh := make(chan term.Event, 1)
h := FuncBindings(func(w Widget, event term.Event) bool {
widgetCh <- w
eventCh <- event
return event == term.K('a')
})
w := Empty{}
event := term.K('a')
handled := h.Handle(w, event)
if !handled {
t.Errorf("should handle")
}
if gotWidget := <-widgetCh; gotWidget != w {
t.Errorf("function called with widget %v, want %v", gotWidget, w)
}
if gotEvent := <-eventCh; gotEvent != event {
t.Errorf("function called with event %v, want %v", gotEvent, event)
}
event = term.K('b')
handled = h.Handle(w, event)
if handled {
t.Errorf("should not handle")
}
if gotEvent := <-eventCh; gotEvent != event {
t.Errorf("function called with event %v, want %v", gotEvent, event)
}
}
elvish-0.20.1/pkg/cli/tty.go 0000664 0000000 0000000 00000005301 14570151573 0015572 0 ustar 00root root 0000000 0000000 package cli
import (
"fmt"
"os"
"os/signal"
"sync"
"src.elv.sh/pkg/cli/term"
"src.elv.sh/pkg/sys"
)
// TTY is the type the terminal dependency of the editor needs to satisfy.
type TTY interface {
// Setup sets up the terminal for the CLI app.
//
// This method returns a restore function that undoes the setup, and any
// error during setup. It only returns fatal errors that make the terminal
// unsuitable for later operations; non-fatal errors may be reported by
// showing a warning message, but not returned.
//
// This method should be called before any other method is called.
Setup() (restore func(), err error)
// ReadEvent reads a terminal event.
ReadEvent() (term.Event, error)
// SetRawInput requests the next n ReadEvent calls to read raw events. It
// is applicable to environments where events are represented as a special
// sequences, such as VT100. It is a no-op if events are delivered as whole
// units by the terminal, such as Windows consoles.
SetRawInput(n int)
// CloseReader releases resources allocated for reading terminal events.
CloseReader()
term.Writer
// NotifySignals start relaying signals and returns a channel on which
// signals are delivered.
NotifySignals() <-chan os.Signal
// StopSignals stops the relaying of signals. After this function returns,
// the channel returned by NotifySignals will no longer deliver signals.
StopSignals()
// Size returns the height and width of the terminal.
Size() (h, w int)
}
type aTTY struct {
in, out *os.File
r term.Reader
term.Writer
sigCh chan os.Signal
rawMutex sync.Mutex
raw int
}
// NewTTY returns a new TTY from input and output terminal files.
func NewTTY(in, out *os.File) TTY {
return &aTTY{in: in, out: out, Writer: term.NewWriter(out)}
}
func (t *aTTY) Setup() (func(), error) {
restore, err := term.Setup(t.in, t.out)
return func() {
err := restore()
if err != nil {
fmt.Println(t.out, "failed to restore terminal properties:", err)
}
}, err
}
func (t *aTTY) Size() (h, w int) {
return sys.WinSize(t.out)
}
func (t *aTTY) ReadEvent() (term.Event, error) {
if t.r == nil {
t.r = term.NewReader(t.in)
}
if t.consumeRaw() {
return t.r.ReadRawEvent()
}
return t.r.ReadEvent()
}
func (t *aTTY) consumeRaw() bool {
t.rawMutex.Lock()
defer t.rawMutex.Unlock()
if t.raw <= 0 {
return false
}
t.raw--
return true
}
func (t *aTTY) SetRawInput(n int) {
t.rawMutex.Lock()
defer t.rawMutex.Unlock()
t.raw = n
}
func (t *aTTY) CloseReader() {
if t.r != nil {
t.r.Close()
}
t.r = nil
}
func (t *aTTY) NotifySignals() <-chan os.Signal {
t.sigCh = sys.NotifySignals()
return t.sigCh
}
func (t *aTTY) StopSignals() {
signal.Stop(t.sigCh)
close(t.sigCh)
t.sigCh = nil
}
elvish-0.20.1/pkg/cli/tty_unix_test.go 0000664 0000000 0000000 00000001627 14570151573 0017703 0 ustar 00root root 0000000 0000000 //go:build unix
package cli_test
import (
"os"
"testing"
"golang.org/x/sys/unix"
. "src.elv.sh/pkg/cli"
)
func TestTTYSignal(t *testing.T) {
tty := NewTTY(os.Stdin, os.Stderr)
sigch := tty.NotifySignals()
err := unix.Kill(unix.Getpid(), unix.SIGUSR1)
if err != nil {
t.Skip("cannot send SIGUSR1 to myself:", err)
}
if sig := nextSig(sigch); sig != unix.SIGUSR1 {
t.Errorf("Got signal %v, want SIGUSR1", sig)
}
tty.StopSignals()
err = unix.Kill(unix.Getpid(), unix.SIGUSR2)
if err != nil {
t.Skip("cannot send SIGUSR2 to myself:", err)
}
if sig := nextSig(sigch); sig != nil {
t.Errorf("Got signal %v, want nil", sig)
}
}
// Gets the next signal from the channel, ignoring all SIGURG generated by the
// Go runtime. See https://github.com/golang/go/issues/37942.
func nextSig(sigch <-chan os.Signal) os.Signal {
for {
sig := <-sigch
if sig != unix.SIGURG {
return sig
}
}
}
elvish-0.20.1/pkg/daemon/ 0000775 0000000 0000000 00000000000 14570151573 0015120 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/daemon/activate.go 0000664 0000000 0000000 00000014062 14570151573 0017252 0 ustar 00root root 0000000 0000000 package daemon
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
"src.elv.sh/pkg/daemon/daemondefs"
"src.elv.sh/pkg/daemon/internal/api"
"src.elv.sh/pkg/fsutil"
)
var (
daemonSpawnTimeout = time.Second
daemonSpawnWaitPerLoop = 10 * time.Millisecond
daemonKillTimeout = time.Second
daemonKillWaitPerLoop = 10 * time.Millisecond
)
type daemonStatus int
const (
daemonOK daemonStatus = iota
sockfileMissing
sockfileOtherError
connectionRefused
connectionOtherError
daemonOutdated
)
const connectionRefusedFmt = "Socket file %s exists but refuses requests. This is likely because the daemon was terminated abnormally. Going to remove socket file and re-spawn the daemon.\n"
// Activate returns a daemon client, either by connecting to an existing daemon,
// or spawning a new one. It always returns a non-nil client, even if there was an error.
func Activate(stderr io.Writer, spawnCfg *daemondefs.SpawnConfig) (daemondefs.Client, error) {
sockpath := spawnCfg.SockPath
cl := NewClient(sockpath)
status, err := detectDaemon(sockpath, cl)
shouldSpawn := false
switch status {
case daemonOK:
case sockfileMissing:
shouldSpawn = true
case sockfileOtherError:
return cl, fmt.Errorf("socket file %s inaccessible: %w", sockpath, err)
case connectionRefused:
fmt.Fprintf(stderr, connectionRefusedFmt, sockpath)
err := os.Remove(sockpath)
if err != nil {
return cl, fmt.Errorf("failed to remove socket file: %w", err)
}
shouldSpawn = true
case connectionOtherError:
return cl, fmt.Errorf("unexpected RPC error on socket %s: %w", sockpath, err)
case daemonOutdated:
fmt.Fprintln(stderr, "Daemon is outdated; going to kill old daemon and re-spawn")
err := killDaemon(sockpath, cl)
if err != nil {
return cl, fmt.Errorf("failed to kill old daemon: %w", err)
}
shouldSpawn = true
default:
return cl, fmt.Errorf("code bug: unknown daemon status %d", status)
}
if !shouldSpawn {
return cl, nil
}
err = spawn(spawnCfg)
if err != nil {
return cl, fmt.Errorf("failed to spawn daemon: %w", err)
}
// Wait for daemon to come online
start := time.Now()
for time.Since(start) < daemonSpawnTimeout {
cl.ResetConn()
status, err := detectDaemon(sockpath, cl)
switch status {
case daemonOK:
return cl, nil
case sockfileMissing:
// Continue waiting
case sockfileOtherError:
return cl, fmt.Errorf("socket file %s inaccessible: %w", sockpath, err)
case connectionRefused:
// Continue waiting
case connectionOtherError:
return cl, fmt.Errorf("unexpected RPC error on socket %s: %w", sockpath, err)
case daemonOutdated:
return cl, fmt.Errorf("code bug: newly spawned daemon is outdated")
default:
return cl, fmt.Errorf("code bug: unknown daemon status %d", status)
}
time.Sleep(daemonSpawnWaitPerLoop)
}
return cl, fmt.Errorf("daemon did not come up within %v", daemonSpawnTimeout)
}
func detectDaemon(sockpath string, cl daemondefs.Client) (daemonStatus, error) {
_, err := os.Lstat(sockpath)
if err != nil {
if os.IsNotExist(err) {
return sockfileMissing, err
}
return sockfileOtherError, err
}
version, err := cl.Version()
if err != nil {
if errors.Is(err, errConnRefused) {
return connectionRefused, err
}
return connectionOtherError, err
}
if version < api.Version {
return daemonOutdated, nil
}
return daemonOK, nil
}
func killDaemon(sockpath string, cl daemondefs.Client) error {
pid, err := cl.Pid()
if err != nil {
return fmt.Errorf("kill daemon: %w", err)
}
process, err := os.FindProcess(pid)
if err != nil {
return fmt.Errorf("kill daemon: %w", err)
}
err = process.Signal(os.Interrupt)
if err != nil {
return fmt.Errorf("kill daemon: %w", err)
}
// Wait until the old daemon has removed the socket file, so that it doesn't
// inadvertently remove the socket file of the new daemon we will start.
start := time.Now()
for time.Since(start) < daemonKillTimeout {
_, err := os.Lstat(sockpath)
if err == nil {
time.Sleep(daemonKillWaitPerLoop)
} else if os.IsNotExist(err) {
return nil
} else {
return fmt.Errorf("kill daemon: %w", err)
}
}
return fmt.Errorf("kill daemon: daemon did not remove socket within %v", daemonKillTimeout)
}
// Can be overridden in tests to avoid actual forking.
var startProcess = func(name string, argv []string, attr *os.ProcAttr) error {
_, err := os.StartProcess(name, argv, attr)
return err
}
// Spawns a daemon process in the background by invoking BinPath, passing
// BinPath, DbPath and SockPath as command-line arguments after resolving them
// to absolute paths. The daemon log file is created in RunDir, and the stdout
// and stderr of the daemon is redirected to the log file.
//
// A suitable ProcAttr is chosen depending on the OS and makes sure that the
// daemon is detached from the current terminal, so that it is not affected by
// I/O or signals in the current terminal and keeps running after the current
// process quits.
func spawn(cfg *daemondefs.SpawnConfig) error {
binPath, err := os.Executable()
if err != nil {
return errors.New("cannot find elvish: " + err.Error())
}
dbPath, err := abs("DbPath", cfg.DbPath)
if err != nil {
return err
}
sockPath, err := abs("SockPath", cfg.SockPath)
if err != nil {
return err
}
args := []string{
binPath,
"-daemon",
"-db", dbPath,
"-sock", sockPath,
}
// The daemon does not read any input; open DevNull and use it for stdin. We
// could also just close the stdin, but on Unix that would make the first
// file opened by the daemon take FD 0.
in, err := os.OpenFile(os.DevNull, os.O_RDONLY, 0)
if err != nil {
return err
}
defer in.Close()
out, err := fsutil.ClaimFile(cfg.RunDir, "daemon-*.log")
if err != nil {
return err
}
defer out.Close()
procattrs := procAttrForSpawn([]*os.File{in, out, out})
err = startProcess(binPath, args, procattrs)
return err
}
func abs(name, path string) (string, error) {
if path == "" {
return "", fmt.Errorf("%s is required for spawning daemon", name)
}
absPath, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("cannot resolve %s to absolute path: %s", name, err)
}
return absPath, nil
}
elvish-0.20.1/pkg/daemon/activate_test.go 0000664 0000000 0000000 00000005772 14570151573 0020321 0 ustar 00root root 0000000 0000000 package daemon
import (
"io"
"net"
"os"
"runtime"
"testing"
"time"
"src.elv.sh/pkg/daemon/daemondefs"
"src.elv.sh/pkg/must"
"src.elv.sh/pkg/testutil"
)
func TestActivate_ConnectsToExistingServer(t *testing.T) {
setup(t)
startServer(t, cli("sock", "db"))
_, err := Activate(io.Discard,
&daemondefs.SpawnConfig{DbPath: "db", SockPath: "sock", RunDir: "."})
if err != nil {
t.Errorf("got error %v, want nil", err)
}
}
func TestActivate_SpawnsNewServer(t *testing.T) {
activated := 0
setupForActivate(t, func(name string, argv []string, attr *os.ProcAttr) error {
startServer(t, argv)
activated++
return nil
})
_, err := Activate(io.Discard,
&daemondefs.SpawnConfig{DbPath: "db", SockPath: "sock", RunDir: "."})
if err != nil {
t.Errorf("got error %v, want nil", err)
}
if activated != 1 {
t.Errorf("got activated %v times, want 1", activated)
}
}
func TestActivate_RemovesHangingSocketAndSpawnsNewServer(t *testing.T) {
activated := 0
setupForActivate(t, func(name string, argv []string, attr *os.ProcAttr) error {
startServer(t, argv)
activated++
return nil
})
makeHangingUnixSocket(t, "sock")
_, err := Activate(io.Discard,
&daemondefs.SpawnConfig{DbPath: "db", SockPath: "sock", RunDir: "."})
if err != nil {
t.Errorf("got error %v, want nil", err)
}
if activated != 1 {
t.Errorf("got activated %v times, want 1", activated)
}
}
func TestActivate_FailsIfCannotStatSock(t *testing.T) {
setup(t)
// Build a path for which Lstat will return a non-nil err such that
// os.IsNotExist(err) is false.
badSockPath := ""
if runtime.GOOS != "windows" {
// POSIX lstat(2) returns ENOTDIR instead of ENOENT if a path prefix is
// not a directory.
must.CreateEmpty("not-dir")
badSockPath = "not-dir/sock"
} else {
// Use a syntactically invalid drive letter on Windows.
badSockPath = `CD:\sock`
}
_, err := Activate(io.Discard,
&daemondefs.SpawnConfig{DbPath: "db", SockPath: badSockPath, RunDir: "."})
if err == nil {
t.Errorf("got error nil, want non-nil")
}
}
func TestActivate_FailsIfCannotDialSock(t *testing.T) {
setup(t)
must.CreateEmpty("sock")
_, err := Activate(io.Discard,
&daemondefs.SpawnConfig{DbPath: "db", SockPath: "sock", RunDir: "."})
if err == nil {
t.Errorf("got error nil, want non-nil")
}
}
func setupForActivate(t *testing.T, f func(string, []string, *os.ProcAttr) error) {
setup(t)
testutil.Set(t, &startProcess, f)
scaleDuration(t, &daemonSpawnTimeout)
scaleDuration(t, &daemonKillTimeout)
}
func scaleDuration(t *testing.T, d *time.Duration) {
testutil.Set(t, d, testutil.Scaled(*d))
}
func makeHangingUnixSocket(t *testing.T, path string) {
t.Helper()
l, err := net.Listen("unix", path)
if err != nil {
t.Fatal(err)
}
// We need to call l.Close() to make the socket hang, but that will
// helpfully remove the socket file. Work around this by renaming the socket
// file.
err = os.Rename(path, path+".save")
if err != nil {
t.Fatal(err)
}
l.Close()
err = os.Rename(path+".save", path)
if err != nil {
t.Fatal(err)
}
}
elvish-0.20.1/pkg/daemon/activate_unix_test.go 0000664 0000000 0000000 00000002743 14570151573 0021357 0 ustar 00root root 0000000 0000000 //go:build unix
package daemon
import (
"io"
"os"
"os/user"
"testing"
"src.elv.sh/pkg/daemon/daemondefs"
"src.elv.sh/pkg/daemon/internal/api"
"src.elv.sh/pkg/must"
)
func TestActivate_InterruptsOutdatedServerAndSpawnsNewServer(t *testing.T) {
activated := 0
setupForActivate(t, func(name string, argv []string, attr *os.ProcAttr) error {
startServer(t, argv)
activated++
return nil
})
version := api.Version - 1
oldServer := startServerOpts(t, cli("sock", "db"), ServeOpts{Version: &version})
_, err := Activate(io.Discard,
&daemondefs.SpawnConfig{DbPath: "db", SockPath: "sock", RunDir: "."})
if err != nil {
t.Errorf("got error %v, want nil", err)
}
if activated != 1 {
t.Errorf("got activated %v times, want 1", activated)
}
oldServer.WaitQuit()
}
func TestActivate_FailsIfUnableToRemoveHangingSocket(t *testing.T) {
if u, err := user.Current(); err != nil || u.Uid == "0" {
t.Skip("current user is root or unknown")
}
activated := 0
setupForActivate(t, func(name string, argv []string, attr *os.ProcAttr) error {
activated++
return nil
})
must.MkdirAll("d")
makeHangingUnixSocket(t, "d/sock")
// Remove write permission so that removing d/sock will fail
os.Chmod("d", 0600)
defer os.Chmod("d", 0700)
_, err := Activate(io.Discard,
&daemondefs.SpawnConfig{DbPath: "db", SockPath: "d/sock", RunDir: "."})
if err == nil {
t.Errorf("got error nil, want non-nil")
}
if activated != 0 {
t.Errorf("got activated %v times, want 0", activated)
}
}
elvish-0.20.1/pkg/daemon/client.go 0000664 0000000 0000000 00000010544 14570151573 0016731 0 ustar 00root root 0000000 0000000 package daemon
import (
"errors"
"net"
"sync"
"src.elv.sh/pkg/daemon/daemondefs"
"src.elv.sh/pkg/daemon/internal/api"
"src.elv.sh/pkg/rpc"
"src.elv.sh/pkg/store/storedefs"
)
const retriesOnShutdown = 3
var (
// ErrDaemonUnreachable is returned when the daemon cannot be reached after
// several retries.
ErrDaemonUnreachable = errors.New("daemon offline")
)
// Implementation of the Client interface.
type client struct {
sockPath string
rpcClient *rpc.Client
waits sync.WaitGroup
}
// NewClient creates a new Client instance that talks to the socket. Connection
// creation is deferred to the first request.
func NewClient(sockPath string) daemondefs.Client {
return &client{sockPath, nil, sync.WaitGroup{}}
}
// SockPath returns the socket path that the Client talks to. If the client is
// nil, it returns an empty string.
func (c *client) SockPath() string {
return c.sockPath
}
// ResetConn resets the current connection. A new connection will be established
// the next time a request is made. If the client is nil, it does nothing.
func (c *client) ResetConn() error {
if c.rpcClient == nil {
return nil
}
rc := c.rpcClient
c.rpcClient = nil
return rc.Close()
}
// Close waits for all outstanding requests to finish and close the connection.
// If the client is nil, it does nothing and returns nil.
func (c *client) Close() error {
c.waits.Wait()
return c.ResetConn()
}
func (c *client) call(f string, req, res any) error {
c.waits.Add(1)
defer c.waits.Done()
for attempt := 0; attempt < retriesOnShutdown; attempt++ {
if c.rpcClient == nil {
conn, err := net.Dial("unix", c.sockPath)
if err != nil {
return err
}
c.rpcClient = rpc.NewClient(conn)
}
err := c.rpcClient.Call(api.ServiceName+"."+f, req, res)
if err == rpc.ErrShutdown {
// Clear rpcClient so as to reconnect next time
c.rpcClient = nil
continue
} else {
return err
}
}
return ErrDaemonUnreachable
}
// Convenience methods for RPC methods. These are quite repetitive; when the
// number of RPC calls grow above some threshold, a code generator should be
// written to generate them.
func (c *client) Version() (int, error) {
req := &api.VersionRequest{}
res := &api.VersionResponse{}
err := c.call("Version", req, res)
return res.Version, err
}
func (c *client) Pid() (int, error) {
req := &api.PidRequest{}
res := &api.PidResponse{}
err := c.call("Pid", req, res)
return res.Pid, err
}
func (c *client) NextCmdSeq() (int, error) {
req := &api.NextCmdRequest{}
res := &api.NextCmdSeqResponse{}
err := c.call("NextCmdSeq", req, res)
return res.Seq, err
}
func (c *client) AddCmd(text string) (int, error) {
req := &api.AddCmdRequest{Text: text}
res := &api.AddCmdResponse{}
err := c.call("AddCmd", req, res)
return res.Seq, err
}
func (c *client) DelCmd(seq int) error {
req := &api.DelCmdRequest{Seq: seq}
res := &api.DelCmdResponse{}
err := c.call("DelCmd", req, res)
return err
}
func (c *client) Cmd(seq int) (string, error) {
req := &api.CmdRequest{Seq: seq}
res := &api.CmdResponse{}
err := c.call("Cmd", req, res)
return res.Text, err
}
func (c *client) CmdsWithSeq(from, upto int) ([]storedefs.Cmd, error) {
req := &api.CmdsWithSeqRequest{From: from, Upto: upto}
res := &api.CmdsWithSeqResponse{}
err := c.call("CmdsWithSeq", req, res)
return res.Cmds, err
}
func (c *client) NextCmd(from int, prefix string) (storedefs.Cmd, error) {
req := &api.NextCmdRequest{From: from, Prefix: prefix}
res := &api.NextCmdResponse{}
err := c.call("NextCmd", req, res)
return storedefs.Cmd{Text: res.Text, Seq: res.Seq}, err
}
func (c *client) PrevCmd(upto int, prefix string) (storedefs.Cmd, error) {
req := &api.PrevCmdRequest{Upto: upto, Prefix: prefix}
res := &api.PrevCmdResponse{}
err := c.call("PrevCmd", req, res)
return storedefs.Cmd{Text: res.Text, Seq: res.Seq}, err
}
func (c *client) AddDir(dir string, incFactor float64) error {
req := &api.AddDirRequest{Dir: dir, IncFactor: incFactor}
res := &api.AddDirResponse{}
err := c.call("AddDir", req, res)
return err
}
func (c *client) DelDir(dir string) error {
req := &api.DelDirRequest{Dir: dir}
res := &api.DelDirResponse{}
err := c.call("DelDir", req, res)
return err
}
func (c *client) Dirs(blacklist map[string]struct{}) ([]storedefs.Dir, error) {
req := &api.DirsRequest{Blacklist: blacklist}
res := &api.DirsResponse{}
err := c.call("Dirs", req, res)
return res.Dirs, err
}
elvish-0.20.1/pkg/daemon/daemondefs/ 0000775 0000000 0000000 00000000000 14570151573 0017225 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/daemon/daemondefs/daemondefs.go 0000664 0000000 0000000 00000001735 14570151573 0021667 0 ustar 00root root 0000000 0000000 // Package daemondefs contains definitions used for the daemon.
//
// It is a separate package so that packages that only depend on the daemon
// API does not need to depend on the concrete implementation.
package daemondefs
import (
"io"
"src.elv.sh/pkg/store/storedefs"
)
// Client represents a daemon client.
type Client interface {
storedefs.Store
ResetConn() error
Close() error
Pid() (int, error)
SockPath() string
Version() (int, error)
}
// ActivateFunc is a function that activates a daemon client, possibly by
// spawning a new daemon and connecting to it.
type ActivateFunc func(stderr io.Writer, spawnCfg *SpawnConfig) (Client, error)
// SpawnConfig keeps configurations for spawning the daemon.
type SpawnConfig struct {
// DbPath is the path to the database.
DbPath string
// SockPath is the path to the socket on which the daemon will serve
// requests.
SockPath string
// RunDir is the directory in which to place the daemon log file.
RunDir string
}
elvish-0.20.1/pkg/daemon/internal/ 0000775 0000000 0000000 00000000000 14570151573 0016734 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/daemon/internal/api/ 0000775 0000000 0000000 00000000000 14570151573 0017505 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/daemon/internal/api/api.go 0000664 0000000 0000000 00000003067 14570151573 0020613 0 ustar 00root root 0000000 0000000 // Package api defines types and constants useful for the API between the daemon
// service and client.
package api
import (
"src.elv.sh/pkg/store/storedefs"
)
// Version is the API version. It should be bumped any time the API changes.
const Version = -93
// ServiceName is the name of the RPC service exposed by the daemon.
const ServiceName = "Daemon"
// Basic requests.
type VersionRequest struct{}
type VersionResponse struct {
Version int
}
type PidRequest struct{}
type PidResponse struct {
Pid int
}
// Cmd requests.
type NextCmdSeqRequest struct{}
type NextCmdSeqResponse struct {
Seq int
}
type AddCmdRequest struct {
Text string
}
type AddCmdResponse struct {
Seq int
}
type DelCmdRequest struct {
Seq int
}
type DelCmdResponse struct {
}
type CmdRequest struct {
Seq int
}
type CmdResponse struct {
Text string
}
type CmdsRequest struct {
From int
Upto int
}
type CmdsResponse struct {
Cmds []string
}
type CmdsWithSeqRequest struct {
From int
Upto int
}
type CmdsWithSeqResponse struct {
Cmds []storedefs.Cmd
}
type NextCmdRequest struct {
From int
Prefix string
}
type NextCmdResponse struct {
Seq int
Text string
}
type PrevCmdRequest struct {
Upto int
Prefix string
}
type PrevCmdResponse struct {
Seq int
Text string
}
// Dir requests.
type AddDirRequest struct {
Dir string
IncFactor float64
}
type AddDirResponse struct{}
type DelDirRequest struct {
Dir string
}
type DelDirResponse struct{}
type DirsRequest struct {
Blacklist map[string]struct{}
}
type DirsResponse struct {
Dirs []storedefs.Dir
}
elvish-0.20.1/pkg/daemon/server.go 0000664 0000000 0000000 00000010504 14570151573 0016755 0 ustar 00root root 0000000 0000000 // Package daemon implements a service for mediating access to the data store,
// and its client.
//
// Most RPCs exposed by the service correspond to the methods of Store in the
// store package and are not documented here.
package daemon
import (
"net"
"os"
"os/signal"
"syscall"
"src.elv.sh/pkg/daemon/internal/api"
"src.elv.sh/pkg/logutil"
"src.elv.sh/pkg/prog"
"src.elv.sh/pkg/rpc"
"src.elv.sh/pkg/store"
)
var logger = logutil.GetLogger("[daemon] ")
// Program is the daemon subprogram.
type Program struct {
run bool
paths *prog.DaemonPaths
// Used in tests.
serveOpts ServeOpts
}
func (p *Program) RegisterFlags(fs *prog.FlagSet) {
fs.BoolVar(&p.run, "daemon", false,
"[internal flag] Run the storage daemon instead of an Elvish shell")
p.paths = fs.DaemonPaths()
}
func (p *Program) Run(fds [3]*os.File, args []string) error {
if !p.run {
return prog.NextProgram()
}
if len(args) > 0 {
return prog.BadUsage("arguments are not allowed with -daemon")
}
// The stdout is redirected to a unique log file (see the spawn function),
// so just use it for logging.
logutil.SetOutput(fds[1])
setUmaskForDaemon()
exit := Serve(p.paths.Sock, p.paths.DB, p.serveOpts)
return prog.Exit(exit)
}
// ServeOpts keeps options that can be passed to Serve.
type ServeOpts struct {
// If not nil, will be closed when the daemon is ready to serve requests.
Ready chan<- struct{}
// Causes the daemon to abort if closed or sent any date. If nil, Serve will
// set up its own signal channel by listening to SIGINT and SIGTERM.
Signals <-chan os.Signal
// If not nil, overrides the response of the Version RPC.
Version *int
}
// Serve runs the daemon service, listening on the socket specified by sockpath
// and serving data from dbpath until all clients have exited. See doc for
// ServeOpts for additional options.
func Serve(sockpath, dbpath string, opts ServeOpts) int {
logger.Println("pid is", syscall.Getpid())
logger.Println("going to listen", sockpath)
listener, err := net.Listen("unix", sockpath)
if err != nil {
logger.Printf("failed to listen on %s: %v", sockpath, err)
logger.Println("aborting")
return 2
}
st, err := store.NewStore(dbpath)
if err != nil {
logger.Printf("failed to create storage: %v", err)
logger.Printf("serving anyway")
}
server := rpc.NewServer()
version := api.Version
if opts.Version != nil {
version = *opts.Version
}
server.RegisterName(api.ServiceName, &service{version, st, err})
connCh := make(chan net.Conn, 10)
listenErrCh := make(chan error, 1)
go func() {
for {
conn, err := listener.Accept()
if err != nil {
listenErrCh <- err
close(listenErrCh)
return
}
connCh <- conn
}
}()
sigCh := opts.Signals
if sigCh == nil {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT)
sigCh = ch
}
conns := make(map[net.Conn]struct{})
connDoneCh := make(chan net.Conn, 10)
interrupt := func() {
if len(conns) == 0 {
logger.Println("exiting since there are no clients")
}
logger.Printf("going to close %v active connections", len(conns))
for conn := range conns {
// Ignore the error - if we can't close the connection it's because
// the client has closed it. There is nothing we can do anyway.
conn.Close()
}
}
if opts.Ready != nil {
close(opts.Ready)
}
loop:
for {
select {
case sig := <-sigCh:
logger.Printf("received signal %v", sig)
interrupt()
break loop
case err := <-listenErrCh:
logger.Println("could not listen:", err)
if len(conns) == 0 {
logger.Println("exiting since there are no clients")
break loop
}
logger.Println("continuing to serve until all existing clients exit")
case conn := <-connCh:
conns[conn] = struct{}{}
go func() {
server.ServeConn(conn)
connDoneCh <- conn
}()
case conn := <-connDoneCh:
delete(conns, conn)
if len(conns) == 0 {
logger.Println("all clients disconnected, exiting")
break loop
}
}
}
err = os.Remove(sockpath)
if err != nil {
logger.Printf("failed to remove socket %s: %v", sockpath, err)
}
if st != nil {
err = st.Close()
if err != nil {
logger.Printf("failed to close storage: %v", err)
}
}
err = listener.Close()
if err != nil {
logger.Printf("failed to close listener: %v", err)
}
// Ensure that the listener goroutine has exited before returning
<-listenErrCh
return 0
}
elvish-0.20.1/pkg/daemon/server_test.go 0000664 0000000 0000000 00000010132 14570151573 0020011 0 ustar 00root root 0000000 0000000 package daemon
import (
"os"
"syscall"
"testing"
"time"
"src.elv.sh/pkg/daemon/daemondefs"
"src.elv.sh/pkg/daemon/internal/api"
"src.elv.sh/pkg/must"
. "src.elv.sh/pkg/prog/progtest"
"src.elv.sh/pkg/store/storetest"
"src.elv.sh/pkg/testutil"
)
func TestProgram_TerminatesIfCannotListen(t *testing.T) {
setup(t)
must.CreateEmpty("sock")
Test(t, &Program{},
ThatElvish("-daemon", "-sock", "sock", "-db", "db").
ExitsWith(2).
WritesStdoutContaining("failed to listen on sock"),
)
}
func TestProgram_ServesClientRequests(t *testing.T) {
setup(t)
startServer(t, cli("sock", "db"))
client := startClient(t, "sock")
// Test server state requests.
gotVersion, err := client.Version()
if gotVersion != api.Version || err != nil {
t.Errorf(".Version() -> (%v, %v), want (%v, nil)", gotVersion, err, api.Version)
}
gotPid, err := client.Pid()
wantPid := syscall.Getpid()
if gotPid != wantPid || err != nil {
t.Errorf(".Pid() -> (%v, %v), want (%v, nil)", gotPid, err, wantPid)
}
// Test store requests.
storetest.TestCmd(t, client)
storetest.TestDir(t, client)
}
func TestProgram_StillServesIfCannotOpenDB(t *testing.T) {
setup(t)
must.WriteFile("db", "not a valid bolt database")
startServer(t, cli("sock", "db"))
client := startClient(t, "sock")
_, err := client.AddCmd("cmd")
if err == nil {
t.Errorf("got nil error, want non-nil")
}
}
func TestProgram_QuitsOnSignalChannelWithNoClient(t *testing.T) {
setup(t)
sigCh := make(chan os.Signal)
startServerOpts(t, cli("sock", "db"), ServeOpts{Signals: sigCh})
close(sigCh)
// startServerSigCh will wait for server to terminate at cleanup
}
func TestProgram_QuitsOnSignalChannelWithClients(t *testing.T) {
setup(t)
sigCh := make(chan os.Signal)
server := startServerOpts(t, cli("sock", "db"), ServeOpts{Signals: sigCh})
client := startClient(t, "sock")
close(sigCh)
server.WaitQuit()
_, err := client.Version()
if err == nil {
t.Errorf("client.Version() returns nil error, want non-nil")
}
}
func TestProgram_BadCLI(t *testing.T) {
Test(t, &Program{},
ThatElvish().
ExitsWith(2).
WritesStderr("internal error: no suitable subprogram\n"),
ThatElvish("-daemon", "x").
ExitsWith(2).
WritesStderrContaining("arguments are not allowed with -daemon"),
)
}
func setup(t *testing.T) {
testutil.Umask(t, 0)
testutil.InTempDir(t)
}
// Calls startServerOpts with a Signals channel that gets closed during cleanup.
func startServer(t *testing.T, args []string) server {
t.Helper()
sigCh := make(chan os.Signal)
s := startServerOpts(t, args, ServeOpts{Signals: sigCh})
// Cleanup functions added later are run earlier. This will be run before
// the cleanup function added by startServerOpts that waits for the server
// to terminate.
t.Cleanup(func() { close(sigCh) })
return s
}
// Start server with custom ServeOpts (opts.Ready is ignored). Makes sure that
// the server terminates during cleanup.
func startServerOpts(t *testing.T, args []string, opts ServeOpts) server {
t.Helper()
readyCh := make(chan struct{})
opts.Ready = readyCh
doneCh := make(chan serverResult)
go func() {
exit, stdout, stderr := Run(&Program{serveOpts: opts}, args...)
doneCh <- serverResult{exit, stdout, stderr}
close(doneCh)
}()
select {
case <-readyCh:
case <-time.After(testutil.Scaled(2 * time.Second)):
t.Fatal("timed out waiting for daemon to start")
}
s := server{t, doneCh}
t.Cleanup(func() { s.WaitQuit() })
return s
}
type server struct {
t *testing.T
ch <-chan serverResult
}
type serverResult struct {
exit int
stdout, stderr string
}
func (s server) WaitQuit() (serverResult, bool) {
s.t.Helper()
select {
case r := <-s.ch:
return r, true
case <-time.After(testutil.Scaled(2 * time.Second)):
s.t.Error("timed out waiting for daemon to quit")
return serverResult{}, false
}
}
func cli(sock, db string) []string {
return []string{"elvish", "-daemon", "-sock", sock, "-db", db}
}
func startClient(t *testing.T, sock string) daemondefs.Client {
cl := NewClient("sock")
if _, err := cl.Version(); err != nil {
t.Errorf("failed to start client: %v", err)
}
t.Cleanup(func() { cl.Close() })
return cl
}
elvish-0.20.1/pkg/daemon/server_unix_test.go 0000664 0000000 0000000 00000001173 14570151573 0021061 0 ustar 00root root 0000000 0000000 //go:build unix
package daemon
import (
"os"
"syscall"
"testing"
)
func TestProgram_QuitsOnSystemSignal_SIGINT(t *testing.T) {
testProgram_QuitsOnSystemSignal(t, syscall.SIGINT)
}
func TestProgram_QuitsOnSystemSignal_SIGTERM(t *testing.T) {
testProgram_QuitsOnSystemSignal(t, syscall.SIGTERM)
}
func testProgram_QuitsOnSystemSignal(t *testing.T, sig os.Signal) {
t.Helper()
setup(t)
startServerOpts(t, cli("sock", "db"), ServeOpts{Signals: nil})
p, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatalf("FindProcess: %v", err)
}
p.Signal(sig)
// startServerOpts will wait for server to terminate at cleanup
}
elvish-0.20.1/pkg/daemon/service.go 0000664 0000000 0000000 00000004704 14570151573 0017114 0 ustar 00root root 0000000 0000000 package daemon
import (
"syscall"
"src.elv.sh/pkg/daemon/internal/api"
"src.elv.sh/pkg/store/storedefs"
)
// A net/rpc service for the daemon.
type service struct {
version int
store storedefs.Store
err error
}
// Implementations of RPC methods.
// Version returns the API version number.
func (s *service) Version(req *api.VersionRequest, res *api.VersionResponse) error {
res.Version = s.version
return nil
}
// Pid returns the process ID of the daemon.
func (s *service) Pid(req *api.PidRequest, res *api.PidResponse) error {
res.Pid = syscall.Getpid()
return nil
}
func (s *service) NextCmdSeq(req *api.NextCmdSeqRequest, res *api.NextCmdSeqResponse) error {
if s.err != nil {
return s.err
}
seq, err := s.store.NextCmdSeq()
res.Seq = seq
return err
}
func (s *service) AddCmd(req *api.AddCmdRequest, res *api.AddCmdResponse) error {
if s.err != nil {
return s.err
}
seq, err := s.store.AddCmd(req.Text)
res.Seq = seq
return err
}
func (s *service) DelCmd(req *api.DelCmdRequest, res *api.DelCmdResponse) error {
if s.err != nil {
return s.err
}
err := s.store.DelCmd(req.Seq)
return err
}
func (s *service) Cmd(req *api.CmdRequest, res *api.CmdResponse) error {
if s.err != nil {
return s.err
}
text, err := s.store.Cmd(req.Seq)
res.Text = text
return err
}
func (s *service) CmdsWithSeq(req *api.CmdsWithSeqRequest, res *api.CmdsWithSeqResponse) error {
if s.err != nil {
return s.err
}
cmds, err := s.store.CmdsWithSeq(req.From, req.Upto)
res.Cmds = cmds
return err
}
func (s *service) NextCmd(req *api.NextCmdRequest, res *api.NextCmdResponse) error {
if s.err != nil {
return s.err
}
cmd, err := s.store.NextCmd(req.From, req.Prefix)
res.Seq, res.Text = cmd.Seq, cmd.Text
return err
}
func (s *service) PrevCmd(req *api.PrevCmdRequest, res *api.PrevCmdResponse) error {
if s.err != nil {
return s.err
}
cmd, err := s.store.PrevCmd(req.Upto, req.Prefix)
res.Seq, res.Text = cmd.Seq, cmd.Text
return err
}
func (s *service) AddDir(req *api.AddDirRequest, res *api.AddDirResponse) error {
if s.err != nil {
return s.err
}
return s.store.AddDir(req.Dir, req.IncFactor)
}
func (s *service) DelDir(req *api.DelDirRequest, res *api.DelDirResponse) error {
if s.err != nil {
return s.err
}
return s.store.DelDir(req.Dir)
}
func (s *service) Dirs(req *api.DirsRequest, res *api.DirsResponse) error {
if s.err != nil {
return s.err
}
dirs, err := s.store.Dirs(req.Blacklist)
res.Dirs = dirs
return err
}
elvish-0.20.1/pkg/daemon/sys_unix.go 0000664 0000000 0000000 00000000732 14570151573 0017332 0 ustar 00root root 0000000 0000000 //go:build unix
package daemon
import (
"os"
"syscall"
"golang.org/x/sys/unix"
)
var errConnRefused = syscall.ECONNREFUSED
// Make sure that files created by the daemon is not accessible to other users.
func setUmaskForDaemon() { unix.Umask(0077) }
func procAttrForSpawn(files []*os.File) *os.ProcAttr {
return &os.ProcAttr{
Dir: "/",
Env: []string{},
Files: files,
Sys: &syscall.SysProcAttr{
Setsid: true, // detach from current terminal
},
}
}
elvish-0.20.1/pkg/daemon/sys_windows.go 0000664 0000000 0000000 00000001556 14570151573 0020046 0 ustar 00root root 0000000 0000000 package daemon
import (
"os"
"syscall"
)
// https://docs.microsoft.com/en-us/windows/win32/winsock/windows-sockets-error-codes-2
var errConnRefused = syscall.Errno(10061)
// No-op on Windows.
func setUmaskForDaemon() {}
// A subset of possible process creation flags, value taken from
// https://msdn.microsoft.com/en-us/library/windows/desktop/ms684863(v=vs.85).aspx
const (
createBreakwayFromJob = 0x01000000
createNewProcessGroup = 0x00000200
detachedProcess = 0x00000008
daemonCreationFlags = createBreakwayFromJob | createNewProcessGroup | detachedProcess
)
func procAttrForSpawn(files []*os.File) *os.ProcAttr {
return &os.ProcAttr{
Dir: `C:\`,
Env: []string{"SystemRoot=" + os.Getenv("SystemRoot")}, // SystemRoot is needed for net.Listen for some reason
Files: files,
Sys: &syscall.SysProcAttr{CreationFlags: daemonCreationFlags},
}
}
elvish-0.20.1/pkg/diag/ 0000775 0000000 0000000 00000000000 14570151573 0014561 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/diag/context.go 0000664 0000000 0000000 00000007442 14570151573 0016603 0 ustar 00root root 0000000 0000000 package diag
import (
"fmt"
"strings"
)
// Context stores information derived from a range in some text. It is used for
// errors that point to a part of the source code, including parse errors,
// compilation errors and a single traceback entry in an exception.
//
// Context values should only be constructed using [NewContext].
type Context struct {
Name string
Ranging
// 1-based line and column numbers of the start position.
StartLine, StartCol int
// 1-based line and column numbers of the end position, inclusive. Note that
// if the range is zero-width, EndCol will be StartCol - 1.
EndLine, EndCol int
// The relevant text, text before its the first line and the text after its
// last line.
Body, Head, Tail string
}
// NewContext creates a new Context.
func NewContext(name, source string, r Ranger) *Context {
rg := r.Range()
d := getContextDetails(source, rg)
return &Context{name, rg,
d.startLine, d.startCol, d.endLine, d.endCol, d.body, d.head, d.tail}
}
// Show shows the context.
//
// If the body has only one line, it returns one line like:
//
// foo.elv:12:7-11: lorem ipsum
//
// If the body has multiple lines, it shows the body in an indented block:
//
// foo.elv:12:1-13:5
// lorem
// ipsum
//
// The body is underlined.
func (c *Context) Show(indent string) string {
rangeDesc := c.describeRange()
if c.StartLine == c.EndLine {
// Body has only one line, show it on the same line:
//
return fmt.Sprintf("%s: %s",
rangeDesc, showContextText(indent, c.Head, c.Body, c.Tail))
}
indent += " "
return fmt.Sprintf("%s:\n%s%s",
rangeDesc, indent, showContextText(indent, c.Head, c.Body, c.Tail))
}
func (c *Context) describeRange() string {
if c.StartLine == c.EndLine {
if c.EndCol < c.StartCol {
// Since EndCol is inclusive, zero-width ranges result in EndCol =
// StartCol - 1.
return fmt.Sprintf("%s:%d:%d", c.Name, c.StartLine, c.StartCol)
}
return fmt.Sprintf("%s:%d:%d-%d",
c.Name, c.StartLine, c.StartCol, c.EndCol)
}
return fmt.Sprintf("%s:%d:%d-%d:%d",
c.Name, c.StartLine, c.StartCol, c.EndLine, c.EndCol)
}
// Variables controlling the style used in [*Context.Show]. Can be overridden in
// tests.
var (
ContextBodyStartMarker = "\033[1;4m"
ContextBodyEndMarker = "\033[m"
)
func showContextText(indent, head, body, tail string) string {
var sb strings.Builder
sb.WriteString(head)
for i, line := range strings.Split(body, "\n") {
if i > 0 {
sb.WriteByte('\n')
sb.WriteString(indent)
}
sb.WriteString(ContextBodyStartMarker)
sb.WriteString(line)
sb.WriteString(ContextBodyEndMarker)
}
sb.WriteString(tail)
return sb.String()
}
// Information about the lines that contain the culprit.
type contextDetails struct {
startLine, startCol int
endLine, endCol int
body, head, tail string
}
func getContextDetails(source string, r Ranging) contextDetails {
before := source[:r.From]
body := source[r.From:r.To]
after := source[r.To:]
head := lastLine(before)
// If the body ends with a newline, stripe it, and leave the tail empty.
// Otherwise, don't process the body and calculate the tail.
var tail string
if strings.HasSuffix(body, "\n") {
body = body[:len(body)-1]
} else {
tail = firstLine(after)
}
startLine := strings.Count(before, "\n") + 1
startCol := 1 + len(head)
endLine := startLine + strings.Count(body, "\n")
var endCol int
if startLine == endLine {
endCol = startCol + len(body) - 1
} else {
endCol = len(lastLine(body))
}
return contextDetails{startLine, startCol, endLine, endCol, body, head, tail}
}
func firstLine(s string) string {
i := strings.IndexByte(s, '\n')
if i == -1 {
return s
}
return s[:i]
}
func lastLine(s string) string {
// When s does not contain '\n', LastIndexByte returns -1, which happens to
// be what we want.
return s[strings.LastIndexByte(s, '\n')+1:]
}
elvish-0.20.1/pkg/diag/context_test.go 0000664 0000000 0000000 00000002564 14570151573 0017642 0 ustar 00root root 0000000 0000000 package diag
import (
"strings"
"testing"
)
var sourceRangeTests = []struct {
Name string
Context *Context
Indent string
WantShow string
}{
{
Name: "single-line culprit",
Context: contextInParen("[test]", "echo (bad)"),
Indent: "_",
WantShow: dedent(`
[test]:1:6-10: echo <(bad)>`),
},
{
Name: "multi-line culprit",
Context: contextInParen("[test]", "echo (bad\nbad)\nmore"),
Indent: "_",
WantShow: dedent(`
[test]:1:6-2:4:
_ echo <(bad>
_ .*? ") {
t.Skip("markdown contains ")
}
if strings.Contains(original, "s $string... # greater
# >=s $string... # greater or equal
# ```
#
# String comparisons. They behave similarly to their number counterparts when
# given multiple arguments. Examples:
#
# ```elvish-transcript
# ~> >s lorem ipsum
# ▶ $true
# ~> ==s 1 1.0
# ▶ $false
# ~> >s 8 12
# ▶ $true
# ```
#doc:id str-cmp
#doc:fn s >=s
# Output the width of `$string` when displayed on the terminal. Examples:
#
# ```elvish-transcript
# ~> wcswidth a
# ▶ 1
# ~> wcswidth lorem
# ▶ 5
# ~> wcswidth 你好,世界
# ▶ 10
# ```
fn wcswidth {|string| }
# Convert arguments to string values.
#
# ```elvish-transcript
# ~> to-string foo [a] [&k=v]
# ▶ foo
# ▶ '[a]'
# ▶ '[&k=v]'
# ```
fn to-string {|@value| }
# Outputs a string for each `$number` written in `$base`. The `$base` must be
# between 2 and 36, inclusive. Examples:
#
# ```elvish-transcript
# ~> base 2 1 3 4 16 255
# ▶ 1
# ▶ 11
# ▶ 100
# ▶ 10000
# ▶ 11111111
# ~> base 16 1 3 4 16 255
# ▶ 1
# ▶ 3
# ▶ 4
# ▶ 10
# ▶ ff
# ```
fn base {|base @number| }
# Deprecated alias for [`re:awk`](). Will be removed in 0.21.0.
fn eawk {|&sep='[ \t]+' &sep-posix=$false &sep-longest=$false f inputs?| }
elvish-0.20.1/pkg/eval/builtin_fn_str.go 0000664 0000000 0000000 00000007061 14570151573 0020160 0 ustar 00root root 0000000 0000000 package eval
import (
"errors"
"fmt"
"math"
"math/big"
"regexp"
"strconv"
"strings"
"src.elv.sh/pkg/eval/errs"
"src.elv.sh/pkg/eval/vals"
"src.elv.sh/pkg/wcwidth"
)
// String operations.
// TODO(xiaq): Document -override-wcswidth.
func init() {
addBuiltinFns(map[string]any{
"s": func(a, b string) bool { return a > b },
">=s": func(a, b string) bool { return a >= b },
"to-string": toString,
"base": base,
"wcswidth": wcwidth.Of,
"-override-wcwidth": wcwidth.Override,
"eawk": Eawk,
})
}
func toString(fm *Frame, args ...any) error {
out := fm.ValueOutput()
for _, a := range args {
err := out.Put(vals.ToString(a))
if err != nil {
return err
}
}
return nil
}
func base(fm *Frame, b int, nums ...vals.Num) error {
if b < 2 || b > 36 {
return errs.OutOfRange{What: "base",
ValidLow: "2", ValidHigh: "36", Actual: strconv.Itoa(b)}
}
// Don't output anything yet in case some arguments are invalid.
results := make([]string, len(nums))
for i, num := range nums {
switch num := num.(type) {
case int:
results[i] = strconv.FormatInt(int64(num), b)
case *big.Int:
results[i] = num.Text(b)
case float64:
if i64 := int64(num); float64(i64) == num {
results[i] = strconv.FormatInt(i64, b)
} else if num == math.Trunc(num) {
var z big.Int
z.SetString(fmt.Sprintf("%.0f", num), 10)
results[i] = z.Text(b)
} else {
return errs.BadValue{What: "number",
Valid: "integer", Actual: vals.ReprPlain(num)}
}
default:
return errs.BadValue{What: "number",
Valid: "integer", Actual: vals.ReprPlain(num)}
}
}
out := fm.ValueOutput()
for _, s := range results {
err := out.Put(s)
if err != nil {
return err
}
}
return nil
}
// ErrInputOfEawkMustBeString is thrown when eawk gets a non-string input.
//
// TODO: Change the message to say re:awk when eawk is removed.
var ErrInputOfEawkMustBeString = errors.New("input of eawk must be string")
type eawkOpt struct {
Sep string
SepPosix bool
SepLongest bool
}
func (o *eawkOpt) SetDefaultOptions() {
o.Sep = "[ \t]+"
}
// Eawk implements the re:awk command and the deprecated eawk command. It is
// put in this package and exported since this package can't depend on
// src.elv.sh/pkg/mods/re.
func Eawk(fm *Frame, opts eawkOpt, f Callable, inputs Inputs) error {
wordSep, err := makePattern(opts.Sep, opts.SepPosix, opts.SepLongest)
if err != nil {
return err
}
broken := false
inputs(func(v any) {
if broken {
return
}
line, ok := v.(string)
if !ok {
broken = true
err = ErrInputOfEawkMustBeString
return
}
args := []any{line}
for _, field := range wordSep.Split(strings.Trim(line, " \t"), -1) {
args = append(args, field)
}
newFm := fm.Fork("fn of eawk")
// TODO: Close port 0 of newFm.
ex := f.Call(newFm, args, NoOpts)
newFm.Close()
if ex != nil {
switch Reason(ex) {
case nil, Continue:
// nop
case Break:
broken = true
default:
broken = true
err = ex
}
}
})
return err
}
func makePattern(p string, posix, longest bool) (*regexp.Regexp, error) {
pattern, err := compilePattern(p, posix)
if err != nil {
return nil, err
}
if longest {
pattern.Longest()
}
return pattern, nil
}
func compilePattern(pattern string, posix bool) (*regexp.Regexp, error) {
if posix {
return regexp.CompilePOSIX(pattern)
}
return regexp.Compile(pattern)
}
elvish-0.20.1/pkg/eval/builtin_fn_str_test.elvts 0000664 0000000 0000000 00000007052 14570151573 0021747 0 ustar 00root root 0000000 0000000 /////////////////////
# string comparison #
/////////////////////
~> <=s a a
▶ $true
~> <=s a b
▶ $true
~> <=s b a
▶ $false
~> ==s haha haha
▶ $true
~> ==s 10 10.0
▶ $false
~> !=s haha haha
▶ $false
~> !=s 10 10.1
▶ $true
~> >s a b
▶ $false
~> >s 2 10
▶ $true
~> >=s a a
▶ $true
~> >=s a b
▶ $false
~> >=s b a
▶ $true
/////////////
# to-string #
/////////////
~> to-string str (num 1) $true
▶ str
▶ 1
▶ '$true'
// bubbling output errors
~> to-string str >&-
Exception: port does not support value output
[tty]:1:1-17: to-string str >&-
////////
# base #
////////
~> base 2 1 3 4 16 255
▶ 1
▶ 11
▶ 100
▶ 10000
▶ 11111111
~> base 16 42 233
▶ 2a
▶ e9
// *big.Int
~> base 16 100000000000000000000
▶ 56bc75e2d63100000
~> base 10 0x56bc75e2d63100000
▶ 100000000000000000000
// float64 storing an integer
~> base 16 256.0
▶ 100
// float64 storing an integer that doesn't fit in int64
~> base 16 100000000000000000000.0
▶ 56bc75e2d63100000
// typed number as arguments
~> base (num 16) (num 256)
▶ 100
// bad arguments
~> base 16 1.2
Exception: bad value: number must be integer, but is (num 1.2)
[tty]:1:1-11: base 16 1.2
~> base 8 1/8
Exception: bad value: number must be integer, but is (num 1/8)
[tty]:1:1-10: base 8 1/8
~> base 1 1
Exception: out of range: base must be from 2 to 36, but is 1
[tty]:1:1-8: base 1 1
~> base 37 10
Exception: out of range: base must be from 2 to 36, but is 37
[tty]:1:1-10: base 37 10
// bubbling output error
~> base 2 1 >&-
Exception: port does not support value output
[tty]:1:1-12: base 2 1 >&-
////////////
# wcswidth #
////////////
~> wcswidth 你好
▶ (num 4)
~> -override-wcwidth x 10; wcswidth 1x2x; -override-wcwidth x 1
▶ (num 22)
////////
# eawk #
////////
//with-deprecation-level 19
~> echo " ax by cz \n11\t22 33" | eawk {|@a| put $a[-1] }
▶ cz
▶ 33
## bad input type ##
~> num 42 | eawk {|@a| fail "this should not run" }
Exception: input of eawk must be string
[tty]:1:10-48: num 42 | eawk {|@a| fail "this should not run" }
## propagating exception ##
~> to-lines [1 2 3 4] | eawk {|@a|
if (==s 3 $a[1]) {
fail "stop eawk"
}
put $a[1]
}
▶ 1
▶ 2
Exception: stop eawk
[tty]:3:9-24: fail "stop eawk"
[tty]:1:22-6:1:
to-lines [1 2 3 4] | eawk {|@a|
if (==s 3 $a[1]) {
fail "stop eawk"
}
put $a[1]
}
## break ##
~> to-lines [" a" "b\tc " "d" "e"] | eawk {|@a|
if (==s d $a[1]) {
break
} else {
put $a[-1]
}
}
▶ a
▶ c
## continue ##
~> to-lines [" a" "b\tc " "d" "e"] | eawk {|@a|
if (==s d $a[1]) {
continue
} else {
put $a[-1]
}
}
▶ a
▶ c
▶ e
## parsing docker image ls output with custom separator ##
~> to-lines [
'REPOSITORY TAG IMAGE ID CREATED SIZE'
'
")
} else {
// A span ending in "\\\n" is handled specifically below.
currentSpan.WriteString("\\\n")
finishCurrentSpan()
}
case segLinkOrImageStart:
linkOrImage++
case segLinkOrImageEnd:
linkOrImage--
}
}
finishCurrentSpan()
if len(spans) == 0 {
// If there are no spans left, write an ampersand-escaped newline to
// preserve the paragraph. A run of ampersand-escaped whitespaces seems
// to be the only way to create an empty paragraph in Markdown in the
// first place.
c.write("
")
return
}
for _, ct := range c.containers {
maxWidth -= len(ct.marker)
}
var currentLine strings.Builder
currentLineWidth := 0
startOfParagraph := true
writeCurrentLine := func() {
escaped := c.escapeStartOfLine(currentLine.String(), startOfParagraph, true)
if canStartHTMLBlock(escaped, startOfParagraph) {
if startOfParagraph {
escaped = "
" + escaped
} else {
escaped = " " + escaped
}
}
if !startOfParagraph {
c.startLine()
}
c.write(escaped)
}
startNewLine := func() {
c.finishLine()
currentLine.Reset()
currentLineWidth = 0
startOfParagraph = false
}
for i, span := range spans {
// Only spans ending in a hard line break ends in a newline
hardLineBreak := strings.HasSuffix(span, "\n")
if hardLineBreak {
span = span[:len(span)-1]
}
w := wcwidth.Of(span)
if currentLine.Len() == 0 {
currentLine.WriteString(span)
currentLineWidth = w
} else {
// Determine whether the current span fits onto the current line.
//
// One slightly tricky detail here is that c.escapeStartOfLine may
// insert more text, making the line wider. In reflow mode, the line
// never starts or ends with whitespaces, so the most we have to
// worry about is one backslash.
//
// As a result, if the line's width is exactly maxWidth after
// appending the current span, we need to be extra careful and only
// consider the current span to fit if c.escapeStartOfLine won't
// introduce an additional backslash.
//
// The current implementation of this check is rather inefficient,
// but since the check is done at most once per line, the
// performance might as well be good enough.
fits := false
if currentLineWidth+1+w < maxWidth {
fits = true
} else if currentLineWidth+1+w == maxWidth {
line := currentLine.String() + " " + span
fits = c.escapeStartOfLine(line, startOfParagraph, true) == line
}
if fits {
currentLine.WriteByte(' ')
currentLine.WriteString(span)
currentLineWidth += 1 + w
} else {
writeCurrentLine()
startNewLine()
currentLine.WriteString(span)
currentLineWidth = w
}
}
if hardLineBreak {
writeCurrentLine()
startNewLine()
if i == len(spans)-1 {
// \ at the end of a paragraph becomes a literal \ instead of a
// hard line break. Fix this with an ampersand-escaped
// whitespace, which seems to be the only way to make a
// paragraph end with a hard line break in the first place.
currentLine.WriteString("
")
}
}
}
if currentLine.Len() > 0 {
writeCurrentLine()
}
}
var (
// Pattern for text that can be parsed as thematic break, possibly after
// prepending the some bullet markers.
//
// - We don't need to consider leading spaces, since they will already be
// ampersand-escaped.
//
// - We don't need to consider "*", since it is always backslash-escaped.
thematicBreakLookalike = regexp.MustCompile(`^((?:-[ \t]*)+|(?:_[ \t]*)+)$`)
// Pattern for dash bullets at the end of the buffer.
trailingDashes = regexp.MustCompile(`(?:- *)*$`)
// Pattern for text that can be parsed as an ATX heading opener, if followed
// by space, tab or end of line.
atxHeadingOpenerLookalike = regexp.MustCompile(`^#{1,6}`)
// Pattern for text that can be parsed as an ordered list opener, if
// followed by space, tab or end of line.
orderedListOpenerLookalike = regexp.MustCompile(`^([0-9]{1,9})([.)])`)
)
func (c *FmtCodec) escapeStartOfLine(s string, startOfParagraph, endOfLine bool) string {
s = escapeLeadingSpaceTab(s)
switch s[0] {
case '-', '+':
tail := s[1:]
if startsWithSpaceOrTab(tail) || (tail == "" && startOfParagraph && endOfLine) {
return `\` + s
}
case '>':
return `\` + s
case '#':
if hashes := atxHeadingOpenerLookalike.FindString(s); hashes != "" {
tail := s[len(hashes):]
if startsWithSpaceOrTab(tail) || (tail == "" && endOfLine) {
return `\` + s
}
}
}
if strings.HasPrefix(s, "~~~") {
return `\` + s
} else if m := orderedListOpenerLookalike.FindStringSubmatch(s); m != nil {
tail := s[len(m[0]):]
if startsWithSpaceOrTab(tail) || (tail == "" && endOfLine) {
number, punct := m[1], m[2]
if startOfParagraph || strings.TrimLeft(number, "0") == "1" {
return number + `\` + punct + tail
}
}
} else if endOfLine && thematicBreakLookalike.MatchString(s) {
// If a line contains a single segment, there is a danger for
// the text to be parsed as a thematic break.
//
// After the escaping above, the text cannot start of end with a
// space or tab; the thematicBreakLookalikeRegexp match furthers
// guarantees that the text starts with either "-" or "_".
line := s
if startOfParagraph && s[0] == '-' {
// If we are the start of a paragraph, we also need to include
// bullet markers that can be merged with the text to form a
// thematic break.
//
// This can only happen for "-": "*" in the content is already
// backslash-escaped at this point, while "_" is not a possible
// bullet list marker.
line = trailingDashes.FindString(c.sb.String()) + line
}
if thematicBreakRegexp.MatchString(line) {
return `\` + s
}
}
return s
}
// Whether an inline raw HTML element can be parsed as the first line of an HTML
// block.
func canStartHTMLBlock(s string, startOfParagraph bool) bool {
return strings.HasPrefix(s, "<") && (html1Regexp.MatchString(s) ||
html2Regexp.MatchString(s) ||
html3Regexp.MatchString(s) ||
html4Regexp.MatchString(s) ||
html5Regexp.MatchString(s) ||
html6Regexp.MatchString(s) ||
html7Regexp.MatchString(s) && startOfParagraph)
}
func escapeLeadingSpaceTab(s string) string {
switch s[0] {
case ' ':
return " " + s[1:]
case '\t':
return "	" + s[1:]
}
return s
}
func escapeTrailingSpaceTab(s string) string {
switch s[len(s)-1] {
case ' ':
return s[:len(s)-1] + " "
case '\t':
return s[:len(s)-1] + "	"
}
return s
}
func startsWithSpaceOrTab(s string) bool {
return s != "" && (s[0] == ' ' || s[0] == '\t')
}
func endsWithSpaceOrTab(s string) bool {
return s != "" && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t')
}
func emphasisOutputStartsWithPunct(op InlineOp) bool {
switch op.Type {
case OpText:
r, l := utf8.DecodeRuneInString(op.Text)
// If the content starts with a space, it will be escaped into " "
return l > 0 && unicode.IsSpace(r) || isUnicodePunct(r)
default:
return true
}
}
func emphasisOutputEndsWithPunct(op InlineOp) bool {
switch op.Type {
case OpText:
r, l := utf8.DecodeLastRuneInString(op.Text)
// If the content starts with a space, it will be escaped into " "
return l > 0 && unicode.IsSpace(r) || isUnicodePunct(r)
default:
return true
}
}
func matchLens(pieces []string, pattern *regexp.Regexp) map[int]bool {
hasRunWithLen := make(map[int]bool)
for _, piece := range pieces {
for _, run := range pattern.FindAllString(piece, -1) {
hasRunWithLen[len(run)] = true
}
}
return hasRunWithLen
}
const asciiControl = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f"
const forbiddenInRawLinkDest = asciiControl + " "
func formatLinkTail(dest, title string) string {
var sb strings.Builder
sb.WriteString("(")
if strings.ContainsAny(dest, forbiddenInRawLinkDest) || !balancedParens(dest) {
// Angle-bracketed destinations recognize a few characters plus
// character references as special and disallow newlines. The order of
// function calls is important here to avoid double-escaping.
sb.WriteString("<" + strings.ReplaceAll(
escapeAmpersandBackslash(dest, "<>"), "\n", "
") + ">")
} else if dest == "" && title != "" {
sb.WriteString("<>")
} else {
// Bare destinations only recognize backslash and character references
// as special. The order of function calls is important here to avoid
// double-escaping.
escapedDest := escapeAmpersandBackslash(dest, "")
// Also escape any leading < so that it won't be parsed as an
// angle-bracketed destination.
if strings.HasPrefix(escapedDest, "<") {
escapedDest = `\` + escapedDest
}
sb.WriteString(escapedDest)
}
if title != "" {
sb.WriteString(" ")
sb.WriteString(escapeNewLines(wrapAndEscapeLinkTitle(title)))
}
sb.WriteString(")")
return sb.String()
}
func balancedParens(s string) bool {
balance := 0
for i := 0; i < len(s); i++ {
switch s[i] {
case '(':
balance++
case ')':
if balance == 0 {
return false
}
balance--
}
}
return balance == 0
}
func wrapAndEscapeLinkTitle(title string) string {
doubleQuotes := strings.Count(title, "\"")
if doubleQuotes == 0 {
return "\"" + escapeAmpersandBackslash(title, "") + "\""
}
singleQuotes := strings.Count(title, "'")
if singleQuotes == 0 {
return "'" + escapeAmpersandBackslash(title, "") + "'"
}
parens := strings.Count(title, "(") + strings.Count(title, ")")
if parens == 0 {
return "(" + escapeAmpersandBackslash(title, "") + ")"
}
switch {
case doubleQuotes <= singleQuotes && doubleQuotes <= parens:
return `"` + escapeAmpersandBackslash(title, `"`) + `"`
case singleQuotes <= parens:
return "'" + escapeAmpersandBackslash(title, `'`) + "'"
default:
return "(" + escapeAmpersandBackslash(title, "()") + ")"
}
}
// Backslash-escape ampersands, backslashes and bytes in the specified set.
func escapeAmpersandBackslash(s, set string) string {
var sb strings.Builder
for i := 0; i < len(s); i++ {
if s[i] == '\\' || strings.IndexByte(set, s[i]) >= 0 || leadingCharRef(s[i:]) != "" {
sb.WriteByte('\\')
}
sb.WriteByte(s[i])
}
return sb.String()
}
func (c *FmtCodec) startLine() { startLine(c, c.containers) }
func (c *FmtCodec) writeLine(s string) { writeLine(c, c.containers, s) }
func (c *FmtCodec) finishLine() { c.write("\n") }
func (c *FmtCodec) write(s string) { c.sb.WriteString(s) }
type writer interface{ write(string) }
func startLine(w writer, containers stack[*fmtContainer]) {
for _, container := range containers {
w.write(container.useMarker())
}
}
func writeLine(w writer, containers stack[*fmtContainer], s string) {
if s == "" {
// When writing a blank line, trim trailing spaces from the markers.
//
// This duplicates startLine, but merges the markers for ease of
// trimming.
var markers strings.Builder
for _, container := range containers {
markers.WriteString(container.useMarker())
}
w.write(strings.TrimRight(markers.String(), " "))
w.write("\n")
return
}
startLine(w, containers)
w.write(s)
w.write("\n")
}
type fmtContainer struct {
typ fmtContainerType
punct rune // punctuation used to build the marker
number int // only used when typ == fmtOrderedItem
marker string // starter or continuation marker
}
type fmtContainerType uint
const (
fmtBlockquote fmtContainerType = iota
fmtBulletItem
fmtOrderedItem
)
func (ct *fmtContainer) useMarker() string {
m := ct.marker
if ct.typ != fmtBlockquote {
ct.marker = strings.Repeat(" ", wcwidth.Of(m))
}
return m
}
func pickPunct(def, alt, banned rune) rune {
if def != banned {
return def
}
return alt
}
func isEmphasisStart(op InlineOp) bool {
return op.Type == OpEmphasisStart || op.Type == OpStrongEmphasisStart
}
func isEmphasisEnd(op InlineOp) bool {
return op.Type == OpEmphasisEnd || op.Type == OpStrongEmphasisEnd
}
func escapeNewLines(s string) string { return strings.ReplaceAll(s, "\n", "
") }
func escapeText(s string) string {
if !strings.ContainsAny(s, "[]*_`\\&<>\u00A0") {
return s
}
var sb strings.Builder
for i, r := range s {
switch r {
case '[', ']', '*', '`', '\\':
sb.WriteByte('\\')
sb.WriteRune(r)
case '_':
if isWord(utf8.DecodeLastRuneInString(s[:i])) && isWord(utf8.DecodeRuneInString(s[i+1:])) {
sb.WriteByte('_')
} else {
sb.WriteString(`\_`)
}
case '&':
// Look ahead decide whether the ampersand can start a character
// reference and thus needs to be escaped. Since any inline markup
// will introduce a metacharacter that is not allowed within
// character reference, it is sufficient to check within the text.
if leadingCharRef(s[i:]) == "" {
sb.WriteByte('&')
} else {
sb.WriteString(`\&`)
}
case '<':
if i < len(s)-1 && !canBeSpecialAfterLt(s[i+1]) {
sb.WriteByte('<')
} else {
sb.WriteString(`\<`)
}
case '\u00A0':
// This is by no means required, but it's nice to make non-breaking
// spaces explicit.
sb.WriteString(" ")
default:
sb.WriteRune(r)
}
}
return sb.String()
}
const forbiddenInAutolink = asciiControl + "& <>"
// The escape of autolinks need to be handled specifically, because they support
// character references, but don't support backslashes. Moreover, characters
// forbidden inside autolinks (see uriAutolinkRegexp) should also be escaped.
func escapeAutolink(s string) string {
if !strings.ContainsAny(s, forbiddenInAutolink) {
return s
}
var sb strings.Builder
for i := 0; i < len(s); i++ {
if s[i] <= 0x20 {
sb.WriteString("" + strconv.Itoa(int(s[i])) + ";")
} else if s[i] == '&' {
if leadingCharRef(s[i:]) == "" {
sb.WriteByte('&')
} else {
sb.WriteString("&")
}
} else if s[i] == '<' {
sb.WriteString("<")
} else if s[i] == '>' {
sb.WriteString(">")
} else {
sb.WriteByte(s[i])
}
}
return sb.String()
}
// Takes the result of utf8.Decode*, and returns whether the character is
// non-empty and a "word" character for the purpose of emphasis parsing.
func isWord(r rune, l int) bool {
return l > 0 && !unicode.IsSpace(r) && !isUnicodePunct(r)
}
func canBeSpecialAfterLt(b byte) bool {
return /* Can form raw HTML */ b == '!' || b == '?' || b != '/' || isASCIILetter(b) ||
/* Can form email autolink */ '0' <= b && b <= '9' || strings.IndexByte(emailLocalPuncts, b) >= 0
}
elvish-0.20.1/pkg/md/fmt_test.go 0000664 0000000 0000000 00000016521 14570151573 0016436 0 ustar 00root root 0000000 0000000 package md_test
import (
"fmt"
"html"
"regexp"
"strings"
"testing"
"unicode/utf8"
"github.com/google/go-cmp/cmp"
. "src.elv.sh/pkg/md"
"src.elv.sh/pkg/testutil"
"src.elv.sh/pkg/wcwidth"
)
var supplementalFmtCases = []testCase{
{
Section: "Fenced code blocks",
Name: "Tilde fence with info starting with tilde",
Markdown: "~~~ ~`\n" + "~~~",
},
{
Section: "Emphasis and strong emphasis",
Name: "Space at start of content",
Markdown: "* x*",
},
{
Section: "Emphasis and strong emphasis",
Name: "Space at end of content",
Markdown: "*x *",
},
{
Section: "Emphasis and strong emphasis",
Name: "Emphasis opener after word before punctuation",
Markdown: "A*!*",
},
{
Section: "Emphasis and strong emphasis",
Name: "Emphasis closer after punctuation before word",
Markdown: "*!*A",
},
{
Section: "Emphasis and strong emphasis",
Name: "Space-only content",
Markdown: "* *",
},
{
Section: "Links",
Name: "Exclamation mark before link",
Markdown: `\`,
},
{
Section: "Links",
Name: "Link title with both single and double quotes",
Markdown: `[a](b ('"))`,
},
{
Section: "Links",
Name: "Link title with fewer double quotes than single quotes and parens",
Markdown: `[a](b "\"''()")`,
},
{
Section: "Links",
Name: "Link title with fewer single quotes than double quotes and parens",
Markdown: `[a](b '\'""()')`,
},
{
Section: "Links",
Name: "Link title with fewer parens than single and double quotes",
Markdown: `[a](b (\(''""))`,
},
{
Section: "Links",
Name: "Newline in link destination",
Markdown: `[a](<
>)`,
},
{
Section: "Soft line breaks",
Name: "Space at start of line",
Markdown: " foo",
},
{
Section: "Soft line breaks",
Name: "Space at end of line",
Markdown: "foo ",
},
}
var fmtTestCases = concat(htmlTestCases, supplementalFmtCases)
func TestFmtPreservesHTMLRender(t *testing.T) {
testutil.Set(t, &UnescapeHTML, html.UnescapeString)
for _, tc := range fmtTestCases {
t.Run(tc.testName(), func(t *testing.T) {
testFmtPreservesHTMLRender(t, tc.Markdown)
})
}
}
func FuzzFmtPreservesHTMLRender(f *testing.F) {
for _, tc := range fmtTestCases {
f.Add(tc.Markdown)
}
f.Fuzz(testFmtPreservesHTMLRender)
}
func testFmtPreservesHTMLRender(t *testing.T, original string) {
testFmtPreservesHTMLRenderModulo(t, original, 0, nil)
}
func TestReflowFmtPreservesHTMLRenderModuleWhitespaces(t *testing.T) {
testReflowFmt(t, testReflowFmtPreservesHTMLRenderModuloWhitespaces)
}
func FuzzReflowFmtPreservesHTMLRenderModuleWhitespaces(f *testing.F) {
fuzzReflowFmt(f, testReflowFmtPreservesHTMLRenderModuloWhitespaces)
}
var (
paragraph = regexp.MustCompile(`(?s)
[ \t\n]*`)
)
func testReflowFmtPreservesHTMLRenderModuloWhitespaces(t *testing.T, original string, w int) {
if strings.Contains(original, "
" + body + "
" }) }) } func TestReflowFmtResultIsUnchangedUnderFmt(t *testing.T) { testReflowFmt(t, testReflowFmtResultIsUnchangedUnderFmt) } func FuzzReflowFmtResultIsUnchangedUnderFmt(f *testing.F) { fuzzReflowFmt(f, testReflowFmtResultIsUnchangedUnderFmt) } func testReflowFmtResultIsUnchangedUnderFmt(t *testing.T, original string, w int) { reflowed := formatAndSkipIfUnsupported(t, original, w) formatted := RenderString(reflowed, &FmtCodec{}) if reflowed != formatted { t.Errorf("original:\n%s\nreflowed:\n%s\nformatted:\n%s"+ "markdown diff (-reflowed +formatted):\n%s", hr+"\n"+original+hr, hr+"\n"+reflowed+hr, hr+"\n"+formatted+hr, cmp.Diff(reflowed, formatted)) } } func TestReflowFmtResultFitsInWidth(t *testing.T) { testReflowFmt(t, testReflowFmtResultFitsInWidth) } func FuzzReflowFmtResultFitsInWidth(f *testing.F) { fuzzReflowFmt(f, testReflowFmtResultFitsInWidth) } var ( // Match all markers that can be written by FmtCodec. markersRegexp = regexp.MustCompile(`^ *(?:(?:[-*>]|[0-9]{1,9}[.)]) *)*`) linkRegexp = regexp.MustCompile(`\[.*\]\(.*\)`) codeSpanRegexp = regexp.MustCompile("`.*`") ) func testReflowFmtResultFitsInWidth(t *testing.T, original string, w int) { if w <= 0 { t.Skip("width <= 0") } var trace TraceCodec Render(original, &trace) for _, op := range trace.Ops() { switch op.Type { case OpHeading, OpCodeBlock, OpHTMLBlock: t.Skipf("input contains unsupported block type %s", op.Type) } } reflowed := formatAndSkipIfUnsupported(t, original, w) for _, line := range strings.Split(reflowed, "\n") { lineWidth := wcwidth.Of(line) if lineWidth <= w { continue } // Strip all markers content := line[len(markersRegexp.FindString(line)):] // Analyze whether the content is allowed to exceed width switch { case !strings.Contains(content, " "): case strings.Contains(content, "<"): case linkRegexp.MatchString(content): case codeSpanRegexp.MatchString(content): default: t.Errorf("line length > %d: %q\nfull reflowed:\n%s", w, line, hr+"\n"+reflowed+hr) } } } var widths = []int{20, 51, 80} func testReflowFmt(t *testing.T, test func(*testing.T, string, int)) { for _, tc := range fmtTestCases { for _, w := range widths { t.Run(fmt.Sprintf("%s/Width %d", tc.testName(), w), func(t *testing.T) { test(t, tc.Markdown, w) }) } } } func fuzzReflowFmt(f *testing.F, test func(*testing.T, string, int)) { for _, tc := range fmtTestCases { for _, w := range widths { f.Add(tc.Markdown, w) } } f.Fuzz(test) } func testFmtPreservesHTMLRenderModulo(t *testing.T, original string, w int, processHTML func(string) string) { formatted := formatAndSkipIfUnsupported(t, original, w) originalRender := RenderString(original, &HTMLCodec{}) formattedRender := RenderString(formatted, &HTMLCodec{}) if processHTML != nil { originalRender = processHTML(originalRender) formattedRender = processHTML(formattedRender) } if formattedRender != originalRender { t.Errorf("original:\n%s\nformatted:\n%s\n"+ "markdown diff (-original +formatted):\n%s"+ "HTML diff (-original +formatted):\n%s"+ "ops diff (-original +formatted):\n%s", hr+"\n"+original+hr, hr+"\n"+formatted+hr, cmp.Diff(original, formatted), cmp.Diff(originalRender, formattedRender), cmp.Diff(RenderString(original, &TraceCodec{}), RenderString(formatted, &TraceCodec{}))) } } func formatAndSkipIfUnsupported(t *testing.T, original string, w int) string { if !utf8.ValidString(original) { t.Skipf("input is not valid UTF-8") } if strings.Contains(original, "\t") { t.Skipf("input contains tab") } codec := &FmtCodec{Width: w} formatted := RenderString(original, codec) if u := codec.Unsupported(); u != nil { t.Skipf("input uses unsupported feature: %v", u) } return formatted } elvish-0.20.1/pkg/md/html.go 0000664 0000000 0000000 00000010104 14570151573 0015544 0 ustar 00root root 0000000 0000000 package md import ( "fmt" "strconv" "strings" ) var ( escapeHTML = strings.NewReplacer( "&", "&", `"`, """, "<", "<", ">", ">", // No need to escape single quotes, since attributes in the output // always use double quotes. ).Replace // Modern browsers will happily accept almost anything in a URL attribute, // except for the quote used by the attribute and space. But we try to be // conservative and escape some characters, mostly following // https://url.spec.whatwg.org/#url-code-points. // // We don't bother escaping control characters as they are unlikely to // appear in Markdown text. escapeURL = strings.NewReplacer( `"`, "%22", `\`, "%5C", " ", "%20", "`", "%60", "[", "%5B", "]", "%5D", "<", "%3C", ">", "%3E").Replace ) // HTMLCodec converts markdown to HTML. type HTMLCodec struct { strings.Builder // If non-nil, will be called for each code block. The return value is // inserted into the HTML output and should be properly escaped. ConvertCodeBlock func(info, code string) string } var tags = []string{ OpThematicBreak: "\n", OpBlockquoteEnd: "\n", OpListItemStart: "
", &attrs)
if c.ConvertCodeBlock != nil {
c.WriteString(c.ConvertCodeBlock(op.Info, strings.Join(op.Lines, "\n")+"\n"))
} else {
for _, line := range op.Lines {
c.WriteString(escapeHTML(line))
c.WriteByte('\n')
}
}
c.WriteString("
\n")
case OpHTMLBlock:
for _, line := range op.Lines {
c.WriteString(line)
c.WriteByte('\n')
}
case OpParagraph:
c.WriteString("") RenderInlineContentToHTML(&c.Builder, op.Content) c.WriteString("
\n") case OpOrderedListStart: var attrs attrBuilder if op.Number != 1 { attrs.set("start", strconv.Itoa(op.Number)) } fmt.Fprintf(c, "")
sb.WriteString(escapeHTML(op.Text))
sb.WriteString("
")
case OpRawHTML:
sb.WriteString(op.Text)
case OpLinkStart:
var attrs attrBuilder
attrs.set("href", escapeURL(op.Dest))
if op.Text != "" {
attrs.set("title", op.Text)
}
fmt.Fprintf(sb, "", &attrs)
case OpImage:
var attrs attrBuilder
attrs.set("src", escapeURL(op.Dest))
attrs.set("alt", op.Alt)
if op.Text != "" {
attrs.set("title", op.Text)
}
fmt.Fprintf(sb, "elvish foo bar ("echo\necho\n")
`)
got := RenderString(markdown, &c)
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("diff (-want +got):\n%s", diff)
}
}
elvish-0.20.1/pkg/md/inline.go 0000664 0000000 0000000 00000046314 14570151573 0016072 0 ustar 00root root 0000000 0000000 package md
import (
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// InlineOp represents an inline operation.
type InlineOp struct {
Type InlineOpType
// OpText, OpCodeSpan, OpRawHTML, OpAutolink: Text content
// OpLinkStart, OpLinkEnd, OpImage: title text
Text string
// OpLinkStart, OpLinkEnd, OpImage, OpAutolink
Dest string
// ForOpImage
Alt string
}
// InlineOpType enumerates possible types of an InlineOp.
type InlineOpType uint
const (
// Text elements. Embedded newlines in OpText are turned into OpNewLine, but
// OpRawHTML can contain embedded newlines. OpCodeSpan never contains
// embedded newlines.
OpText InlineOpType = iota
OpCodeSpan
OpRawHTML
OpNewLine
// Inline markup elements.
OpEmphasisStart
OpEmphasisEnd
OpStrongEmphasisStart
OpStrongEmphasisEnd
OpLinkStart
OpLinkEnd
OpImage
OpAutolink
OpHardLineBreak
)
// String returns the text content of the InlineOp
func (op InlineOp) String() string {
switch op.Type {
case OpText, OpCodeSpan, OpRawHTML, OpAutolink:
return op.Text
case OpNewLine:
return "\n"
case OpImage:
return op.Alt
}
return ""
}
func renderInline(text string) []InlineOp {
p := inlineParser{text, 0, makeDelimStack(), buffer{}}
p.render()
return p.buf.ops()
}
type inlineParser struct {
text string
pos int
delims delimStack
buf buffer
}
func (p *inlineParser) render() {
for p.pos < len(p.text) {
b := p.text[p.pos]
begin := p.pos
p.pos++
parseText := func() {
for p.pos < len(p.text) && !isMeta(p.text[p.pos]) {
p.pos++
}
text := p.text[begin:p.pos]
hardLineBreak := false
if p.pos < len(p.text) && p.text[p.pos] == '\n' {
// https://spec.commonmark.org/0.30/#hard-line-break
//
// The input to renderInline never ends in a newline, so all
// newlines are internal ones, thus subject to the hard line
// break rules
hardLineBreak = strings.HasSuffix(text, " ")
text = strings.TrimRight(text, " ")
}
p.buf.push(textPiece(text))
if hardLineBreak {
p.buf.push(piece{main: InlineOp{Type: OpHardLineBreak}})
}
}
switch b {
// The 3 branches below implement the first part of
// https://spec.commonmark.org/0.30/#an-algorithm-for-parsing-nested-emphasis-and-links.
case '[':
bufIdx := p.buf.push(textPiece("["))
p.delims.push(&delim{typ: '[', bufIdx: bufIdx})
case '!':
if p.pos < len(p.text) && p.text[p.pos] == '[' {
p.pos++
bufIdx := p.buf.push(textPiece("!["))
p.delims.push(&delim{typ: '!', bufIdx: bufIdx})
} else {
parseText()
}
case '*', '_':
p.consumeRun(b)
canOpen, canClose := canOpenCloseEmphasis(rune(b),
emptyToNewline(utf8.DecodeLastRuneInString(p.text[:begin])),
emptyToNewline(utf8.DecodeRuneInString(p.text[p.pos:])))
bufIdx := p.buf.push(textPiece(p.text[begin:p.pos]))
p.delims.push(
&delim{typ: b, bufIdx: bufIdx,
n: p.pos - begin, canOpen: canOpen, canClose: canClose})
case ']':
// https://spec.commonmark.org/0.30/#look-for-link-or-image.
var opener *delim
for d := p.delims.top.prev; d != p.delims.bottom; d = d.prev {
if d.typ == '[' || d.typ == '!' {
opener = d
break
}
}
if opener == nil || opener.inactive {
if opener != nil {
unlink(opener)
}
p.buf.push(textPiece("]"))
continue
}
n, dest, title := parseLinkTail(p.text[p.pos:])
if n == -1 {
unlink(opener)
p.buf.push(textPiece("]"))
continue
}
p.pos += n
p.processEmphasis(opener)
if opener.typ == '[' {
for d := opener.prev; d != p.delims.bottom; d = d.prev {
if d.typ == '[' {
d.inactive = true
}
}
}
unlink(opener)
if opener.typ == '[' {
p.buf.pieces[opener.bufIdx] = piece{
before: []InlineOp{{Type: OpLinkStart, Dest: dest, Text: title}}}
p.buf.push(piece{
after: []InlineOp{{Type: OpLinkEnd, Dest: dest, Text: title}}})
} else {
// Use the pieces after "![" to build the image alt text.
var altBuilder strings.Builder
for _, piece := range p.buf.pieces[opener.bufIdx+1:] {
altBuilder.WriteString(piece.main.String())
}
p.buf.pieces = p.buf.pieces[:opener.bufIdx]
alt := altBuilder.String()
p.buf.push(piece{
main: InlineOp{Type: OpImage, Dest: dest, Alt: alt, Text: title}})
}
case '`':
// https://spec.commonmark.org/0.30/#code-spans
p.consumeRun('`')
closer := findBacktickRun(p.text, p.text[begin:p.pos], p.pos)
if closer == -1 {
// No matching closer, don't parse as code span.
parseText()
continue
}
p.buf.push(piece{
main: InlineOp{Type: OpCodeSpan,
Text: normalizeCodeSpanContent(p.text[p.pos:closer])}})
p.pos = closer + (p.pos - begin)
case '<':
// https://spec.commonmark.org/0.30/#raw-html
if p.pos == len(p.text) {
parseText()
continue
}
parseWithRegexp := func(pattern *regexp.Regexp) bool {
html := pattern.FindString(p.text[begin:])
if html == "" {
return false
}
p.buf.push(htmlPiece(html))
p.pos = begin + len(html)
return true
}
parseWithCloser := func(closer string) bool {
i := strings.Index(p.text[p.pos:], closer)
if i == -1 {
return false
}
p.pos += i + len(closer)
p.buf.push(htmlPiece(p.text[begin:p.pos]))
return true
}
switch p.text[p.pos] {
case '!':
switch {
case strings.HasPrefix(p.text[p.pos:], "!--"):
// Try parsing a comment.
if parseWithCloser("-->") {
continue
}
case strings.HasPrefix(p.text[p.pos:], "![CDATA["):
// Try parsing a CDATA section
if parseWithCloser("]]>") {
continue
}
case p.pos+1 < len(p.text) && isASCIILetter(p.text[p.pos+1]):
// Try parsing a declaration.
if parseWithCloser(">") {
continue
}
}
case '?':
// Try parsing a processing instruction.
closer := strings.Index(p.text[p.pos:], "?>")
if closer != -1 {
p.buf.push(htmlPiece(p.text[begin : p.pos+closer+2]))
p.pos += closer + 2
continue
}
case '/':
// Try parsing a closing tag.
if parseWithRegexp(closingTagRegexp) {
continue
}
default:
// Try parsing a open tag.
if parseWithRegexp(openTagRegexp) {
continue
} else {
// Try parsing an autolink.
autolink := uriAutolinkRegexp.FindString(p.text[begin:])
email := false
if autolink == "" {
autolink = emailAutolinkRegexp.FindString(p.text[begin:])
email = true
}
if autolink != "" {
p.pos = begin + len(autolink)
// Autolinks support character references but not
// backslashes, so UnescapeHTML gives us the desired
// behavior.
text := UnescapeHTML(autolink[1 : len(autolink)-1])
dest := text
if email {
dest = "mailto:" + dest
}
p.buf.push(piece{
main: InlineOp{Type: OpAutolink, Text: text, Dest: dest},
})
continue
}
}
}
parseText()
case '&':
// https://spec.commonmark.org/0.30/#entity-and-numeric-character-references
if entity := leadingCharRef(p.text[begin:]); entity != "" {
p.buf.push(textPiece(UnescapeHTML(entity)))
p.pos = begin + len(entity)
} else {
parseText()
}
case '\\':
// https://spec.commonmark.org/0.30/#backslash-escapes
if p.pos < len(p.text) {
if p.text[p.pos] == '\n' {
// https://spec.commonmark.org/0.30/#hard-line-break
//
// Do *not* consume the newline; "\\\n" is a hard line break
// plus a (soft) line break.
p.buf.push(piece{main: InlineOp{Type: OpHardLineBreak}})
continue
} else if isASCIIPunct(p.text[p.pos]) {
// Valid backslash escape: handle this by just discarding
// the backslash. The parseText call below will consider the
// next byte to be already included in the text content.
begin++
p.pos++
}
}
parseText()
case '\n':
// Hard line breaks are already inserted using lookahead in
// parseText and the case '\\' branch.
p.buf.push(piece{main: InlineOp{Type: OpNewLine}})
// Remove spaces at the beginning of the next line per
// https://spec.commonmark.org/0.30/#soft-line-breaks.
for p.pos < len(p.text) && p.text[p.pos] == ' ' {
p.pos++
}
default:
parseText()
}
}
p.processEmphasis(p.delims.bottom)
}
func (p *inlineParser) consumeRun(b byte) {
for p.pos < len(p.text) && p.text[p.pos] == b {
p.pos++
}
}
// Processes the (rune, int) result of utf8.Decode* so that an empty result is
// converted to '\n'.
func emptyToNewline(r rune, l int) rune {
if l == 0 {
return '\n'
}
return r
}
// Returns whether an emphasis punctuation can open or close an emphasis, when
// following prev and preceding next. Start and end of file should be
// represented by '\n'.
//
// The criteria are described in:
// https://spec.commonmark.org/0.30/#emphasis-and-strong-emphasis
//
// The algorithm is a bit complicated. Here is another way to describe the
// criteria:
//
// - Every rune falls into one of three categories: space, punctuation and
// other. "Other" is the category of word runes in "intraword emphasis".
//
// - The following tables describe whether a punctuation can open or close
// emphasis:
//
// Can open emphasis:
//
// | | next space | next punct | next other |
// | ---------- | ---------- | ---------- | ---------- |
// | prev space | | _ or * | _ or * |
// | prev punct | | _ or * | _ or * |
// | prev other | | | only * |
//
// Can close emphasis:
//
// | | next space | next punct | next other |
// | ---------- | ---------- | ---------- | ---------- |
// | prev space | | | |
// | prev punct | _ or * | _ or * | |
// | prev other | _ or * | _ or * | only * |
func canOpenCloseEmphasis(b, prev, next rune) (bool, bool) {
leftFlanking := !unicode.IsSpace(next) &&
(!isUnicodePunct(next) || unicode.IsSpace(prev) || isUnicodePunct(prev))
rightFlanking := !unicode.IsSpace(prev) &&
(!isUnicodePunct(prev) || unicode.IsSpace(next) || isUnicodePunct(next))
if b == '*' {
return leftFlanking, rightFlanking
}
return leftFlanking && (!rightFlanking || isUnicodePunct(prev)),
rightFlanking && (!leftFlanking || isUnicodePunct(next))
}
// Returns the starting index of the next backtick run identical to the given
// run, starting from i. Returns -1 if no such run exists.
func findBacktickRun(s, run string, i int) int {
for i < len(s) {
j := strings.Index(s[i:], run)
if j == -1 {
return -1
}
j += i
if j+len(run) == len(s) || s[j+len(run)] != '`' {
return j
}
// Too many backticks; skip over the entire run.
for j += len(run); j < len(s) && s[j] == '`'; j++ {
}
i = j
}
return -1
}
func normalizeCodeSpanContent(s string) string {
s = strings.ReplaceAll(s, "\n", " ")
if len(s) > 1 && s[0] == ' ' && s[len(s)-1] == ' ' && strings.Trim(s, " ") != "" {
return s[1 : len(s)-1]
}
return s
}
// https://spec.commonmark.org/0.30/#process-emphasis
func (p *inlineParser) processEmphasis(bottom *delim) {
var openersBottom [2][3][2]*delim
for closer := bottom.next; closer != nil; {
if !closer.canClose {
closer = closer.next
continue
}
openerBottom := &openersBottom[b2i(closer.typ == '_')][closer.n%3][b2i(closer.canOpen)]
if *openerBottom == nil {
*openerBottom = bottom
}
var opener *delim
for p := closer.prev; p != *openerBottom && p != bottom; p = p.prev {
if p.canOpen && p.typ == closer.typ &&
((!p.canClose && !closer.canOpen) ||
(p.n+closer.n)%3 != 0 || (p.n%3 == 0 && closer.n%3 == 0)) {
opener = p
break
}
}
if opener == nil {
*openerBottom = closer.prev
if !closer.canOpen {
closer.prev.next = closer.next
closer.next.prev = closer.prev
}
closer = closer.next
continue
}
openerPiece := &p.buf.pieces[opener.bufIdx]
closerPiece := &p.buf.pieces[closer.bufIdx]
strong := len(openerPiece.main.Text) >= 2 && len(closerPiece.main.Text) >= 2
if strong {
openerPiece.main.Text = openerPiece.main.Text[2:]
openerPiece.append(InlineOp{Type: OpStrongEmphasisStart})
closerPiece.main.Text = closerPiece.main.Text[2:]
closerPiece.prepend(InlineOp{Type: OpStrongEmphasisEnd})
} else {
openerPiece.main.Text = openerPiece.main.Text[1:]
openerPiece.append(InlineOp{Type: OpEmphasisStart})
closerPiece.main.Text = closerPiece.main.Text[1:]
closerPiece.prepend(InlineOp{Type: OpEmphasisEnd})
}
opener.next = closer
closer.prev = opener
if openerPiece.main.Text == "" {
opener.prev.next = opener.next
opener.next.prev = opener.prev
}
if closerPiece.main.Text == "" {
closer.prev.next = closer.next
closer.next.prev = closer.prev
closer = closer.next
}
}
bottom.next = p.delims.top
p.delims.top.prev = bottom
}
func b2i(b bool) int {
if b {
return 1
} else {
return 0
}
}
// Stores output of inline rendering.
type buffer struct {
pieces []piece
}
func (b *buffer) push(p piece) int {
b.pieces = append(b.pieces, p)
return len(b.pieces) - 1
}
func (b *buffer) ops() []InlineOp {
var ops []InlineOp
for _, p := range b.pieces {
p.iterate(func(op InlineOp) {
if op.Type == OpText {
// Convert any embedded newlines into OpNewLine, and merge
// adjacent OpText's or OpRawHTML's.
if op.Text == "" {
return
}
lines := strings.Split(op.Text, "\n")
if len(ops) > 0 && ops[len(ops)-1].Type == op.Type {
ops[len(ops)-1].Text += lines[0]
} else if lines[0] != "" {
ops = append(ops, InlineOp{Type: op.Type, Text: lines[0]})
}
for _, line := range lines[1:] {
ops = append(ops, InlineOp{Type: OpNewLine})
if line != "" {
ops = append(ops, InlineOp{Type: op.Type, Text: line})
}
}
} else {
ops = append(ops, op)
}
})
}
return ops
}
// The algorithm described in
// https://spec.commonmark.org/0.30/#phase-2-inline-structure involves inserting
// nodes before and after existing nodes in the output. The most natural choice
// is a doubly linked list; but for simplicity, we use a slice for output nodes,
// keep track of nodes that need to be prepended or appended to each node.
//
// TODO: Compare the performance of this data structure with doubly linked
// lists.
type piece struct {
before []InlineOp
main InlineOp
after []InlineOp
}
func textPiece(text string) piece {
return piece{main: InlineOp{Type: OpText, Text: text}}
}
func htmlPiece(html string) piece {
return piece{main: InlineOp{Type: OpRawHTML, Text: html}}
}
func (p *piece) prepend(op InlineOp) { p.before = append(p.before, op) }
func (p *piece) append(op InlineOp) { p.after = append(p.after, op) }
func (p *piece) iterate(f func(InlineOp)) {
for _, op := range p.before {
f(op)
}
f(p.main)
for i := len(p.after) - 1; i >= 0; i-- {
f(p.after[i])
}
}
// A delimiter "stack" (actually a doubly linked list), with sentinels as bottom
// and top, with the bottom being the head of the list.
//
// https://spec.commonmark.org/0.30/#delimiter-stack
type delimStack struct {
bottom, top *delim
}
func makeDelimStack() delimStack {
bottom := &delim{}
top := &delim{prev: bottom}
bottom.next = top
return delimStack{bottom, top}
}
func (s *delimStack) push(n *delim) {
n.prev = s.top.prev
n.next = s.top
s.top.prev.next = n
s.top.prev = n
}
// A node in the delimiter "stack".
type delim struct {
typ byte
bufIdx int
prev *delim
next *delim
// Only used when typ is '['
inactive bool
// Only used when typ is '_' or '*'.
n int
canOpen bool
canClose bool
}
func unlink(n *delim) {
n.next.prev = n.prev
n.prev.next = n.next
}
type linkTailParser struct {
text string
pos int
}
// Parses the link "tail", the part after the ] that closes the link text.
func parseLinkTail(text string) (n int, dest, title string) {
p := linkTailParser{text, 0}
return p.parse()
}
// https://spec.commonmark.org/0.30/#links
func (p *linkTailParser) parse() (n int, dest, title string) {
if len(p.text) < 2 || p.text[0] != '(' {
return -1, "", ""
}
p.pos = 1
p.skipWhitespaces()
if p.pos == len(p.text) {
return -1, "", ""
}
// Parse an optional link destination.
var destBuilder strings.Builder
if p.text[p.pos] == '<' {
p.pos++
closed := false
angleDest:
for p.pos < len(p.text) {
switch p.text[p.pos] {
case '>':
p.pos++
closed = true
break angleDest
case '\n', '<':
return -1, "", ""
case '\\':
destBuilder.WriteByte(p.parseBackslash())
case '&':
destBuilder.WriteString(p.parseCharRef())
default:
destBuilder.WriteByte(p.text[p.pos])
p.pos++
}
}
if !closed {
return -1, "", ""
}
} else {
parenBalance := 0
bareDest:
for p.pos < len(p.text) {
if isASCIIControl(p.text[p.pos]) || p.text[p.pos] == ' ' {
break
}
switch p.text[p.pos] {
case '(':
parenBalance++
destBuilder.WriteByte('(')
p.pos++
case ')':
if parenBalance == 0 {
break bareDest
}
parenBalance--
destBuilder.WriteByte(')')
p.pos++
case '\\':
destBuilder.WriteByte(p.parseBackslash())
case '&':
destBuilder.WriteString(p.parseCharRef())
default:
destBuilder.WriteByte(p.text[p.pos])
p.pos++
}
}
if parenBalance != 0 {
return -1, "", ""
}
}
p.skipWhitespaces()
var titleBuilder strings.Builder
if p.pos < len(p.text) && strings.ContainsRune("'\"(", rune(p.text[p.pos])) {
opener := p.text[p.pos]
closer := p.text[p.pos]
if closer == '(' {
closer = ')'
}
p.pos++
title:
for p.pos < len(p.text) {
switch p.text[p.pos] {
case closer:
p.pos++
break title
case opener:
// Titles started with "(" does not allow unescaped "(":
// https://spec.commonmark.org/0.30/#link-title
return -1, "", ""
case '\\':
titleBuilder.WriteByte(p.parseBackslash())
case '&':
titleBuilder.WriteString(p.parseCharRef())
default:
titleBuilder.WriteByte(p.text[p.pos])
p.pos++
}
}
}
p.skipWhitespaces()
if p.pos == len(p.text) || p.text[p.pos] != ')' {
return -1, "", ""
}
return p.pos + 1, destBuilder.String(), titleBuilder.String()
}
func (p *linkTailParser) skipWhitespaces() {
for p.pos < len(p.text) && isWhitespace(p.text[p.pos]) {
p.pos++
}
}
func isWhitespace(b byte) bool { return b == ' ' || b == '\t' || b == '\n' }
func (p *linkTailParser) parseBackslash() byte {
if p.pos+1 < len(p.text) && isASCIIPunct(p.text[p.pos+1]) {
b := p.text[p.pos+1]
p.pos += 2
return b
}
p.pos++
return '\\'
}
func (p *linkTailParser) parseCharRef() string {
if entity := leadingCharRef(p.text[p.pos:]); entity != "" {
p.pos += len(entity)
return UnescapeHTML(entity)
}
p.pos++
return p.text[p.pos-1 : p.pos]
}
func isASCIILetter(b byte) bool { return ('a' <= b && b <= 'z') || ('A' <= b && b <= 'Z') }
func isASCIIControl(b byte) bool { return b < 0x20 }
const asciiPuncts = "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
func isASCIIPunct(b byte) bool { return strings.IndexByte(asciiPuncts, b) >= 0 }
// The CommonMark spec has its own definition of Unicode punctuation:
// https://spec.commonmark.org/0.30/#unicode-punctuation-character
//
// This definition includes all the ASCII punctuations above, some of which
// ("$+<=>^`|~" to be exact) are not considered to be punctuations by
// unicode.IsPunct.
func isUnicodePunct(r rune) bool {
return unicode.IsPunct(r) || r <= 0x7f && isASCIIPunct(byte(r))
}
const metas = "![]*_`\\&<\n"
func isMeta(b byte) bool { return strings.IndexByte(metas, b) >= 0 }
elvish-0.20.1/pkg/md/md.go 0000664 0000000 0000000 00000101266 14570151573 0015212 0 ustar 00root root 0000000 0000000 // Package md implements a Markdown parser.
//
// To use this package, call [Render] with one of the [Codec] implementations:
//
// - [HTMLCodec] converts Markdown to HTML. This is used in
// [src.elv.sh/website/cmd/md2html], part of Elvish's website toolchain.
//
// - [FmtCodec] formats Markdown. This is used in [src.elv.sh/cmd/elvmdfmt],
// used for formatting Markdown files in the Elvish repo.
//
// - [TTYCodec] renders Markdown in the terminal. This will be used in a help
// system that can used directly from Elvish to render documentation of
// Elvish modules.
//
// # Why another Markdown implementation?
//
// The Elvish project uses Markdown in the documentation ("[elvdoc]") for the
// functions and variables defined in builtin modules. These docs are then
// converted to HTML as part of the website; for example, you can read the docs
// for builtin functions and variables at https://elv.sh/ref/builtin.html.
//
// We used to use [Pandoc] to convert the docs from their Markdown sources to
// HTML. However, we would also like to expand the elvdoc system in two ways:
//
// - We would like to support elvdocs in user-defined modules, not just
// builtin modules.
//
// - We would like to allow users to read elvdocs directly from the Elvish
// program, in the terminal, without needing a browser or an Internet
// connection.
//
// With these requirements, Elvish itself needs to know how to parse Markdown
// sources and render them in the terminal, so we need a Go implementation
// instead. There is a good Go implementation, [github.com/yuin/goldmark], but
// it is quite large: linking it into Elvish will increase the binary size by
// more than 1MB. (There is another popular Markdown implementation,
// [github.com/russross/blackfriday/v2], but it doesn't support CommonMark.)
//
// By having a more narrow focus, this package is much smaller than goldmark,
// and can be easily optimized for Elvish's use cases. In contrast to goldmark's
// 1MB, including [Render] and [HTMLCodec] in Elvish only increases the binary
// size by 150KB. That said, the functionalities provided by this package still
// try to be as general as possible, and can potentially be used by other people
// interested in a small Markdown implementation.
//
// Besides elvdocs, Pandoc was also used to convert all the other content on the
// Elvish website (https://elv.sh) to HTML. Additionally, [Prettier] used to be
// used to format all the Markdown files in the repo. Now that Elvish has its
// own Markdown implementation, we can use it not just for rendering elvdocs in
// the terminal, but also replace the use of Pandoc and Prettier. These external
// tools are decent, but using them still came with some frictions:
//
// - Even though both are relatively easy to set up, they can still be a
// hindrance to casual contributors.
//
// - Since behavior of these tools can change with version, we explicit
// specify their versions in both CI configurations and [contributing
// instructions]. But this creates another problem: every time these tools
// release new versions, we have to manually bump the versions, and every
// contributor also needs to manually update them in their development
// environments.
//
// Replacing external tools with this package removes these frictions.
//
// Additionally, this package is very easy to extend and optimize to suit
// Elvish's needs:
//
// - We used to custom Pandoc using a mix of shell scripts, templates and Lua
// scripts. While these customization options of Pandoc are well documented,
// they are not something people are likely to be familiar with.
//
// With this implementation, everything is now done with Go code.
//
// - The Markdown formatter is much faster than Prettier, so it's now feasible
// to run the formatter every time when saving a Markdown file.
//
// # Which Markdown variant does this package implement?
//
// This package implements a large subset of the [CommonMark] spec, with the
// following omissions:
//
// - "\r" and "\r\n" are not supported as line endings. This can be easily
// worked around by converting them to "\n" first.
//
// - Tabs are not supported for defining block structures; use spaces instead.
// Tabs in other context are supported.
//
// - Among HTML entities, only a few are supported: < > "e; '
// &. This is because the full list of HTML entities is very large and
// will inflate the binary size.
//
// If full support for HTML entities are desirable, this can be done by
// overriding the [UnescapeHTML] variable with [html.UnescapeString].
//
// (Numeric character references like and are fully supported.)
//
// - [Setext headings] are not supported; use [ATX headings] instead.
//
// - [Reference links] are not supported; use [inline links] instead.
//
// - Lists are always considered [loose].
//
// These omitted features are never used in Elvish's Markdown sources.
//
// All implemented features pass their relevant CommonMark spec tests. See
// [testutils_test.go] for a complete list of which spec tests are skipped.
//
// Note: the spec tests were taken from the [CommonMark spec Git repo] on
// 2022-09-26. This version is almost identical to the latest released version,
// [CommonMark 0.30] (released 2021-06-09), with two minor changes in the syntax
// of [HTML blocks] and [inline HTML comments]. Once CommonMark 0.31 is
// released, the spec tests will be updated to follow that instead.
//
// # Is this package useful outside Elvish?
//
// Yes! Well, hopefully. Assuming you don't use the features this package omits,
// it can be useful in at least the following ways:
//
// - The implementation is quite lightweight, so you can use it instead of a
// more full-features Markdown library if small binary size is important.
//
// As shown above, the increase in binary size when using this package in
// Elvish is about 150KB, compared to more than 1MB when using
// [github.com/yuin/goldmark]. You mileage may vary though, since the binary
// size increase depends on which packages the binary is already including.
//
// - The formatter implemented by [FmtCodec] is heavily fuzz-tested to ensure
// that it does not alter the semantics of the Markdown.
//
// Markdown formatting is fraught with tricky edge cases. For example, if a
// formatter standardizes all bullet markers to "-", it might reformat "*
// --" to "- ---", but the latter will now be parsed as a thematic break.
//
// Thanks to Go's builtin [fuzzing support], the formatter is able to handle
// many such corner cases (at least [all the corner cases found by the
// fuzzer]; take a look and try them on other formatters!). There are two
// areas - namely nested and consecutive emphasis or strong emphasis - that
// are just too tricky to get 100% right that the formatter is not
// guaranteed to be correct; the fuzz test explicitly skips those cases.
//
// Nonetheless, if you are writing a Markdown formatter and care about
// correctness, the corner cases will be interesting, regardless of which
// language you are using to implement the formatter.
//
// [all the corner cases found by the fuzzer]: https://github.com/elves/elvish/tree/master/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender
// [fuzzing support]: https://go.dev/security/fuzz/
// [CommonMark 0.30]: https://spec.commonmark.org/0.30/
// [HTML blocks]: https://github.com/commonmark/commonmark-spec/commit/053924aa51ea56db1899403068540f90b761125a
// [inline HTML comments]: https://github.com/commonmark/commonmark-spec/commit/d5ddfae696c53f09fd5b6182238de716dddeb40a
// [loose]: https://spec.commonmark.org/0.30/#loose
// [Setext headings]: https://spec.commonmark.org/0.30/#setext-headings
// [ATX headings]: https://spec.commonmark.org/0.30/#atx-headings
// [testutils_test.go]: https://github.com/elves/elvish/blob/master/pkg/md/testutils_test.go
// [elvdoc]: https://github.com/elves/elvish/blob/master/CONTRIBUTING.md#reference-docs
// [Pandoc]: https://pandoc.org
// [Prettier]: https://prettier.io
// [CommonMark]: https://spec.commonmark.org
// [contributing instructions]: https://github.com/elves/elvish/blob/master/CONTRIBUTING.md
// [inline links]: https://spec.commonmark.org/0.30/#inline-link
// [Reference links]: https://spec.commonmark.org/0.30/#reference-link
// [CommonMark spec Git repo]: https://github.com/commonmark/commonmark-spec
package md
//go:generate stringer -type=OpType,InlineOpType -output=zstring.go
import (
"fmt"
"regexp"
"strconv"
"strings"
"sync"
)
// UnescapeHTML is used by the parser to unescape HTML entities and numeric
// character references.
//
// The default implementation supports numeric character references, plus a
// minimal set of entities that are necessary for writing valid HTML or can
// appear in the output of FmtCodec. It can be set to html.UnescapeString for
// better CommonMark compliance.
var UnescapeHTML = unescapeHTML
// https://spec.commonmark.org/0.30/#entity-and-numeric-character-references
const charRefPattern = `&(?:[a-zA-Z0-9]+|#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6});`
var charRefRegexp = regexp.MustCompile(charRefPattern)
var entities = map[string]rune{
// Necessary for writing valid HTML
"lt": '<', "gt": '>', "quote": '"', "apos": '\'', "amp": '&',
// Not strictly necessary, but could be output by FmtCodec for slightly
// nicer text
"Tab": '\t', "NewLine": '\n', "nbsp": '\u00A0',
}
func unescapeHTML(s string) string {
return charRefRegexp.ReplaceAllStringFunc(s, func(entity string) string {
body := entity[1 : len(entity)-1]
if r, ok := entities[body]; ok {
return string(r)
} else if body[0] == '#' {
if body[1] == 'x' || body[1] == 'X' {
if num, err := strconv.ParseInt(body[2:], 16, 32); err == nil {
return string(rune(num))
}
} else {
if num, err := strconv.ParseInt(body[1:], 10, 32); err == nil {
return string(rune(num))
}
}
}
return entity
})
}
// Codec is used to render output.
type Codec interface {
Do(Op)
}
// Op represents an operation for the Codec.
type Op struct {
Type OpType
// For OpOrderedListStart (the start number) or OpHeading (as the heading
// level)
Number int
// For OpHeading and OpCodeBlock
Info string
// For OpCodeBlock and OpHTMLBlock
Lines []string
// For OpParagraph and OpHeading
Content []InlineOp
}
// OpType enumerates possible types of an Op.
type OpType uint
// Possible output operations.
const (
// Leaf blocks.
OpThematicBreak OpType = iota
OpHeading
OpCodeBlock
OpHTMLBlock
OpParagraph
// Container blocks.
OpBlockquoteStart
OpBlockquoteEnd
OpListItemStart
OpListItemEnd
OpBulletListStart
OpBulletListEnd
OpOrderedListStart
OpOrderedListEnd
)
var initRegexpsOnce sync.Once
// Render parses markdown and renders it with a [Codec].
func Render(text string, codec Codec) {
// Compiled regular expressions live on the heap. Compiling them lazily
// saves memory if this function is never called.
initRegexpsOnce.Do(initRegexps)
p := blockParser{lines: lineSplitter{text, 0}, codec: codec}
p.render()
}
// StringerCodec is a [Codec] that also implements the String method.
type StringerCodec interface {
Codec
String() string
}
// Render calls Render(text, codec) and returns codec.String(). This can be a
// bit more convenient to use than [Render].
func RenderString(text string, codec StringerCodec) string {
Render(text, codec)
return codec.String()
}
type blockParser struct {
lines lineSplitter
codec Codec
tree blockTree
}
// Block regexps.
var thematicBreakRegexp,
atxHeadingRegexp,
atxHeadingCloserRegexp,
atxHeadingAttributeRegexp,
codeFenceRegexp,
codeFenceCloserRegexp,
html1Regexp,
html1CloserRegexp,
html2Regexp,
html2CloserRegexp,
html3Regexp,
html3CloserRegexp,
html4Regexp,
html4CloserRegexp,
html5Regexp,
html5CloserRegexp,
html6Regexp,
html7Regexp *regexp.Regexp
// Inline regexps.
var uriAutolinkRegexp,
emailAutolinkRegexp,
openTagRegexp,
closingTagRegexp *regexp.Regexp
// Building blocks for regexps.
const (
scheme = `[a-zA-Z][a-zA-Z0-9+.-]{1,31}`
emailLocalPuncts = ".!#$%&'*+/=?^_`{|}~-"
// https://spec.commonmark.org/0.30/#open-tag
openTag = `<` +
`[a-zA-Z][a-zA-Z0-9-]*` + // tag name
(`(?:` +
`[ \t\n]+` + // whitespace
`[a-zA-Z_:][a-zA-Z0-9_\.:-]*` + // attribute name
`(?:[ \t\n]*=[ \t\n]*(?:[^ \t\n"'=<>` + "`" + `]+|'[^']*'|"[^"]*"))?` + // attribute value specification
`)*`) + // zero or more attributes
`[ \t\n]*` + // whitespace
`/?>`
// https://spec.commonmark.org/0.30/#closing-tag
closingTag = `[a-zA-Z][a-zA-Z0-9-]*[ \t\n]*>`
)
func initRegexps() {
thematicBreakRegexp = regexp.MustCompile(
`^ {0,3}((?:-[ \t]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})$`)
// Capture group 1: heading opener
atxHeadingRegexp = regexp.MustCompile(`^ {0,3}(#{1,6})(?:[ \t]|$)`)
atxHeadingCloserRegexp = regexp.MustCompile(`[ \t]#+[ \t]*$`)
// Support the header_attributes extension
// (https://pandoc.org/MANUAL.html#extension-header_attributes). Like
// pandoc, attributes appear *after* the optional heading closer.
//
// Attributes are stored in the info string and interpreted by the Codec.
atxHeadingAttributeRegexp = regexp.MustCompile(` {([^}]+)}$`)
// Capture groups:
// 1. Indent
// 2. Fence punctuations (backquote fence)
// 3. Untrimmed info string (backquote fence)
// 4. Fence punctuations (tilde fence)
// 5. Untrimmed info string (tilde fence)
codeFenceRegexp = regexp.MustCompile("(^ {0,3})(?:(`{3,})([^`]*)|(~{3,})(.*))$")
// Capture group 1: fence punctuations
codeFenceCloserRegexp = regexp.MustCompile("(?:^ {0,3})(`{3,}|~{3,})[ \t]*$")
html1Regexp = regexp.MustCompile(`^ {0,3}<(?i:pre|script|style|textarea)`)
html1CloserRegexp = regexp.MustCompile(`(?i:pre|script|style|textarea)`)
html2Regexp = regexp.MustCompile(`^ {0,3}`)
html3Regexp = regexp.MustCompile(`^ {0,3}<\?`)
html3CloserRegexp = regexp.MustCompile(`\?>`)
html4Regexp = regexp.MustCompile(`^ {0,3}`)
html5Regexp = regexp.MustCompile(`^ {0,3}`)
html6Regexp = regexp.MustCompile(`^ {0,3}?(?i:address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h1|h2|h3|h4|h5|h6|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|nav|noframes|ol|optgroup|option|p|param|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?:[ \t>]|$|/>)`)
html7Regexp = regexp.MustCompile(
fmt.Sprintf(`^ {0,3}(?:%s|%s)[ \t]*$`, openTag, closingTag))
// https://spec.commonmark.org/0.30/#uri-autolink
uriAutolinkRegexp = regexp.MustCompile(
`^<` + scheme + `:[^\x00-\x19 <>]*` + `>`)
// https://spec.commonmark.org/0.30/#email-autolink
emailAutolinkRegexp = regexp.MustCompile(
`^<[a-zA-Z0-9` + emailLocalPuncts + `]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*>`)
openTagRegexp = regexp.MustCompile(`^` + openTag)
closingTagRegexp = regexp.MustCompile(`^` + closingTag)
}
const indentedCodePrefix = " "
func (p *blockParser) render() {
for p.lines.more() {
line := p.lines.next()
line, matchedContainers, newItem := p.tree.processContainerMarkers(line, p.codec)
if isBlankLine(line) {
// Blank lines terminate blockquote if the continuation marker is
// absent.
if i, unmatched := p.tree.unmatchedBlockquote(matchedContainers); unmatched {
p.tree.closeBlocks(i, p.codec)
continue
}
if newItem && p.lines.more() {
// A list item can start with at most one blank line; the second
// blank closes it.
nextLine := p.lines.next()
nextLine, _ = p.tree.matchContinuationMarkers(nextLine)
p.lines.backup()
if isBlankLine(nextLine) {
p.tree.closeBlocks(len(p.tree.containers)-1, p.codec)
}
}
p.tree.closeParagraph(p.codec)
} else if thematicBreakRegexp.MatchString(line) {
p.tree.closeBlocks(matchedContainers, p.codec)
p.codec.Do(Op{Type: OpThematicBreak})
} else if m := atxHeadingRegexp.FindStringSubmatchIndex(line); m != nil {
p.tree.closeBlocks(matchedContainers, p.codec)
openerStart, openerEnd := m[2], m[3]
opener := line[openerStart:openerEnd]
line = strings.TrimRight(line[openerEnd:], " \t")
if closer := atxHeadingCloserRegexp.FindString(line); closer != "" {
line = strings.TrimRight(line[:len(line)-len(closer)], " \t")
}
attr := ""
if m := atxHeadingAttributeRegexp.FindStringSubmatch(line); m != nil {
attr = m[1]
line = strings.TrimRight(line[:len(line)-len(m[0])], " \t")
}
level := len(opener)
p.codec.Do(Op{
Type: OpHeading, Number: level, Info: attr,
Content: renderInline(strings.Trim(line, " \t"))})
} else if m := codeFenceRegexp.FindStringSubmatch(line); m != nil {
p.tree.closeBlocks(matchedContainers, p.codec)
indent, opener, info := len(m[1]), m[2], m[3]
if opener == "" {
opener, info = m[4], m[5]
}
p.parseFencedCodeBlock(indent, opener, info)
} else if len(p.tree.paragraph) == 0 && strings.HasPrefix(line, indentedCodePrefix) {
p.tree.closeBlocks(matchedContainers, p.codec)
p.parseIndentedCodeBlock(line)
} else if html1Regexp.MatchString(line) {
p.tree.closeBlocks(matchedContainers, p.codec)
p.parseCloserTerminatedHTMLBlock(line, html1CloserRegexp.MatchString)
} else if html2Regexp.MatchString(line) {
p.tree.closeBlocks(matchedContainers, p.codec)
p.parseCloserTerminatedHTMLBlock(line, html2CloserRegexp.MatchString)
} else if html3Regexp.MatchString(line) {
p.tree.closeBlocks(matchedContainers, p.codec)
p.parseCloserTerminatedHTMLBlock(line, html3CloserRegexp.MatchString)
} else if html4Regexp.MatchString(line) {
p.tree.closeBlocks(matchedContainers, p.codec)
p.parseCloserTerminatedHTMLBlock(line, html4CloserRegexp.MatchString)
} else if html5Regexp.MatchString(line) {
p.tree.closeBlocks(matchedContainers, p.codec)
p.parseCloserTerminatedHTMLBlock(line, html5CloserRegexp.MatchString)
} else if html6Regexp.MatchString(line) || (len(p.tree.paragraph) == 0 && html7Regexp.MatchString(line)) {
p.tree.closeBlocks(matchedContainers, p.codec)
p.parseBlankLineTerminatedHTMLBlock(line)
} else {
if len(p.tree.paragraph) == 0 {
// This is not lazy continuation, so close all unmatched
// containers.
p.tree.closeBlocks(matchedContainers, p.codec)
}
p.tree.paragraph = append(p.tree.paragraph, line)
}
}
p.tree.closeBlocks(0, p.codec)
}
func isBlankLine(line string) bool {
return strings.Trim(line, " \t") == ""
}
func (p *blockParser) parseFencedCodeBlock(indent int, opener, info string) {
// Escaped spaces and tabs (e.g. 	) should also be trimmed, so process
// the info string before trimming.
info = strings.Trim(processCodeFenceInfo(info), " \t")
var lines []string
doCodeBlock := func() {
p.codec.Do(Op{Type: OpCodeBlock, Info: info, Lines: lines})
}
for p.lines.more() {
line := p.lines.next()
line, matchedContainers := p.tree.matchContinuationMarkers(line)
if isBlankLine(line) {
if i, unmatched := p.tree.unmatchedBlockquote(matchedContainers); unmatched {
doCodeBlock()
p.tree.closeBlocks(i, p.codec)
return
}
} else if matchedContainers < len(p.tree.containers) {
p.lines.backup()
doCodeBlock()
return
}
if m := codeFenceCloserRegexp.FindStringSubmatch(line); m != nil {
closer := m[1]
if closer[0] == opener[0] && len(closer) >= len(opener) {
doCodeBlock()
return
}
}
for i := indent; i > 0 && line != "" && line[0] == ' '; i-- {
line = line[1:]
}
lines = append(lines, line)
}
doCodeBlock()
}
// Code fence info strings are mostly verbatim, but support backslash and
// entities. This mirrors part of (*inlineParser).render.
func processCodeFenceInfo(text string) string {
pos := 0
var sb strings.Builder
for pos < len(text) {
b := text[pos]
if b == '&' {
if entity := leadingCharRef(text[pos:]); entity != "" {
sb.WriteString(UnescapeHTML(entity))
pos += len(entity)
continue
}
} else if b == '\\' && pos+1 < len(text) && isASCIIPunct(text[pos+1]) {
b = text[pos+1]
pos++
}
sb.WriteByte(b)
pos++
}
return sb.String()
}
func (p *blockParser) parseIndentedCodeBlock(line string) {
lines := []string{strings.TrimPrefix(line, indentedCodePrefix)}
doCodeBlock := func() { p.codec.Do(Op{Type: OpCodeBlock, Lines: lines}) }
var savedBlankLines []string
for p.lines.more() {
line := p.lines.next()
line, matchedContainers := p.tree.matchContinuationMarkers(line)
if isBlankLine(line) {
if i, unmatched := p.tree.unmatchedBlockquote(matchedContainers); unmatched {
doCodeBlock()
p.tree.closeBlocks(i, p.codec)
return
}
if strings.HasPrefix(line, indentedCodePrefix) {
line = strings.TrimPrefix(line, indentedCodePrefix)
} else {
line = ""
}
savedBlankLines = append(savedBlankLines, line)
continue
} else if matchedContainers < len(p.tree.containers) || !strings.HasPrefix(line, indentedCodePrefix) {
p.lines.backup()
break
}
lines = append(lines, savedBlankLines...)
savedBlankLines = savedBlankLines[:0]
lines = append(lines, strings.TrimPrefix(line, indentedCodePrefix))
}
doCodeBlock()
}
func (p *blockParser) parseCloserTerminatedHTMLBlock(line string, closer func(string) bool) {
lines := []string{line}
doHTMLBlock := func() {
p.codec.Do(Op{Type: OpHTMLBlock, Lines: lines})
}
if closer(line) {
doHTMLBlock()
return
}
var savedBlankLines []string
for p.lines.more() {
line := p.lines.next()
line, matchedContainers := p.tree.matchContinuationMarkers(line)
if isBlankLine(line) {
if i, unmatched := p.tree.unmatchedBlockquote(matchedContainers); unmatched {
doHTMLBlock()
p.tree.closeBlocks(i, p.codec)
return
}
savedBlankLines = append(savedBlankLines, line)
continue
} else if matchedContainers < len(p.tree.containers) {
p.lines.backup()
doHTMLBlock()
return
}
lines = append(lines, savedBlankLines...)
savedBlankLines = savedBlankLines[:0]
lines = append(lines, line)
if closer(line) {
doHTMLBlock()
return
}
}
doHTMLBlock()
}
func (p *blockParser) parseBlankLineTerminatedHTMLBlock(line string) {
lines := []string{line}
doHTMLBlock := func() { p.codec.Do(Op{Type: OpHTMLBlock, Lines: lines}) }
for p.lines.more() {
line := p.lines.next()
line, matchedContainers := p.tree.matchContinuationMarkers(line)
if isBlankLine(line) {
doHTMLBlock()
if i, unmatched := p.tree.unmatchedBlockquote(matchedContainers); unmatched {
p.tree.closeBlocks(i, p.codec)
}
return
} else if matchedContainers < len(p.tree.containers) {
p.lines.backup()
break
}
lines = append(lines, line)
}
doHTMLBlock()
}
// This struct corresponds to the block tree in
// https://spec.commonmark.org/0.30/#phase-1-block-structure.
//
// The spec describes a two-phased parsing strategy where the entire block tree
// is built before inline parsing is done. However, since we don't support
// setext headings and link reference definitions, and treats all lists as
// loose, the rendering result of closed blocks will never be impacted by future
// blocks. This enables us to render as we parse, and allows us to only track
// the path of currently open blocks, which is the same as the rightmost path in
// the full block tree at any given point in time.
//
// The path consists of zero or more container nodes, and an optional paragraph
// node. The paragraph node exists if and only if it contains at least 1 line;
// the spec prohibits paragraphs consisting of 0 lines.
//
// We don't need to track any other type of leaf blocks, because they all have
// simple termination conditions, so can be parsed in one iteration of the main
// parsing loop, as a nested loop that consumes lines until the block
// terminates.
//
// Paragraphs, however, don't have a simple termination condition. Other than
// the common condition of being terminated as part of the container block,
// paragraphs are always terminated by *another* type of leaf block. This means
// that the logic for deciding to continue or interrupt of a paragraph lives
// within the main parsing loop. This in turn makes it necessary to store the
// lines of the paragraph across iterations of the main parsing loop, hence part
// of the parser's state.
type blockTree struct {
containers []container
paragraph []string
}
// Processes container markers at the start of the line, which consists of
// continuation markers of existing containers and starting markers of new
// containers.
//
// Returns the line after removing both types of markers, the number of markers
// matched or parsed, and whether the innermost container is a newly opened list
// item.
//
// The latter should be used to call t.closeContainers
// unless the remaining content of the line constitutes a blank line or
// paragraph continuation.
func (t *blockTree) processContainerMarkers(line string, codec Codec) (string, int, bool) {
line, matched := t.matchContinuationMarkers(line)
line, newContainers := t.parseStartingMarkers(line,
// This argument tells parseStartingMarkers whether we are starting a
// new paragraph. This seems straightforward enough: if the paragraph is
// empty is the first place, or if we are going to terminate some
// containers, we are starting a new paragraph.
//
// The second part of the condition is more subtle though. If the
// remaining content of the line constitutes paragraph continuation, we
// are not starting a new paragraph. We are only able to ignore this
// case parseStartingMarkers only uses this condition when it actually
// parses a starting marker, meaning that the line cannot be paragraph
// continuation.
len(t.paragraph) == 0 || matched != len(t.containers))
continueList := false
if matched > 0 && t.containers[matched-1].typ.isList() {
// If the last matched container is a list (i.e. the first unmatched
// container is a list item), keep it if and only if the first
// container to add is a list item that can continue the list.
continueList = len(newContainers) > 0 && newContainers[0].punct == t.containers[matched-1].punct
if !continueList {
matched--
}
}
if len(newContainers) == 0 {
return line, matched, false
}
t.closeBlocks(matched, codec)
for _, c := range newContainers {
if c.typ.isItem() {
if continueList {
continueList = false
} else {
list := container{typ: c.typ.itemToList(), punct: c.punct, start: c.start}
t.containers = append(t.containers, list)
codec.Do(Op{Type: containerOpenOp[list.typ], Number: list.start})
}
}
t.containers = append(t.containers, c)
codec.Do(Op{Type: containerOpenOp[c.typ]})
}
return line, len(t.containers), newContainers[len(newContainers)-1].typ.isItem()
}
// Matches the continuation markers of existing container nodes. Returns the
// line after removing all matched continuation markers and the number of
// containers matched.
func (t *blockTree) matchContinuationMarkers(line string) (string, int) {
for i, container := range t.containers {
markerLen, matched := container.matchContinuationMarker(line)
if !matched {
return line, i
}
line = line[markerLen:]
}
return line, len(t.containers)
}
// Finds the first blockquote container after skipping matched containers.
// Returns len(t.containers), false if not found.
//
// This is used for handling blank lines. Blank lines do not close list item
// blocks (except when a blank line follows a list item starting with a blank
// item), but they do close blockquote blocks if the continuation marker is
// missing.
func (t *blockTree) unmatchedBlockquote(matched int) (int, bool) {
for i := matched; i < len(t.containers); i++ {
if t.containers[i].typ == blockquote {
return i, true
}
}
return len(t.containers), false
}
var (
// https://spec.commonmark.org/0.30/#block-quotes
blockquoteMarkerRegexp = regexp.MustCompile(`^ {0,3}> ?`)
// Rule #1 and #2 of https://spec.commonmark.org/0.30/#list-items
itemStartingMarkerRegexp = regexp.MustCompile(
// Capture groups:
// 1. bullet item punctuation
// 2. ordered item start index
// 3. ordered item punctuation
// 4. trailing spaces
`^ {0,3}(?:([-+*])|([0-9]{1,9})([.)]))( +)`)
// Rule #3 of https://spec.commonmark.org/0.30/#list-items
itemStartingMarkerBlankLineRegexp = regexp.MustCompile(
// Capture groups are the same, with group 4 always empty.
`^ {0,3}(?:([-+*])|([0-9]{1,9})([.)]))[ \t]*()$`)
)
// Parses starting markers of container blocks. Returns the line after removing
// all starting markers and new containers to create.
//
// Blockquotes are simple to parse. Most of the code deals with list items,
// described in https://spec.commonmark.org/0.30/#list-items.
func (t *blockTree) parseStartingMarkers(line string, newParagraph bool) (string, []container) {
var containers []container
// Exception 2 of rule #1: Don't parse thematic breaks like "- - - " as
// three bullets.
for !thematicBreakRegexp.MatchString(line) {
if bqMarker := blockquoteMarkerRegexp.FindString(line); bqMarker != "" {
line = line[len(bqMarker):]
containers = append(containers, container{typ: blockquote})
continue
}
m := itemStartingMarkerRegexp.FindStringSubmatch(line)
if m == nil && newParagraph {
m = itemStartingMarkerBlankLineRegexp.FindStringSubmatch(line)
}
if m == nil {
break
}
marker, bulletPunct, orderedStart, orderedPunct, spaces := m[0], m[1], m[2], m[3], m[4]
if len(spaces) >= 5 {
// Rule #2 applies; only the first space is as part of the marker.
marker = marker[:len(marker)-len(spaces)+1]
}
indent := len(marker)
if strings.Trim(line[len(marker):], " \t") == "" {
// Rule #3 applies: indent is exactly one space, regardless of how
// many spaces there actually are, which can be 0.
indent = len(strings.TrimRight(marker, " \t")) + 1
}
c := container{continuation: strings.Repeat(" ", indent)}
if bulletPunct != "" {
c.typ = bulletItem
c.punct = bulletPunct[0]
} else {
c.typ = orderedItem
c.punct = orderedPunct[0]
c.start, _ = strconv.Atoi(orderedStart)
if c.start != 1 && !newParagraph {
break
}
}
line = line[len(marker):]
containers = append(containers, c)
// After parsing at least one starting marker, the rest of the line is
// in a new paragraph. This means that bullet list marker can be
// terminated by end of line or tab (instead of space), and ordered list
// marker with number != 1 are allowed.
newParagraph = true
}
return line, containers
}
func (t *blockTree) closeBlocks(keep int, codec Codec) {
t.closeParagraph(codec)
for i := len(t.containers) - 1; i >= keep; i-- {
codec.Do(Op{Type: containerCloseOp[t.containers[i].typ]})
}
t.containers = t.containers[:keep]
}
func (t *blockTree) closeParagraph(codec Codec) {
if len(t.paragraph) == 0 {
return
}
text := strings.Trim(strings.Join(t.paragraph, "\n"), " \t")
t.paragraph = t.paragraph[:0]
codec.Do(Op{Type: OpParagraph, Content: renderInline(text)})
}
type container struct {
typ containerType
punct byte
start int
continuation string
}
type containerType uint8
const (
blockquote containerType = iota
bulletList
bulletItem
orderedList
orderedItem
)
func (t containerType) isList() bool { return t == bulletList || t == orderedList }
func (t containerType) isItem() bool { return t == bulletItem || t == orderedItem }
func (t containerType) itemToList() containerType {
if t == bulletItem {
return bulletList
} else {
return orderedList
}
}
var (
containerOpenOp = []OpType{
blockquote: OpBlockquoteStart,
bulletList: OpBulletListStart,
bulletItem: OpListItemStart,
orderedList: OpOrderedListStart,
orderedItem: OpListItemStart,
}
containerCloseOp = []OpType{
blockquote: OpBlockquoteEnd,
bulletList: OpBulletListEnd,
bulletItem: OpListItemEnd,
orderedList: OpOrderedListEnd,
orderedItem: OpListItemEnd,
}
)
func (c container) matchContinuationMarker(line string) (int, bool) {
switch c.typ {
case blockquote:
marker := blockquoteMarkerRegexp.FindString(line)
return len(marker), marker != ""
case bulletList, orderedList:
return 0, true
case bulletItem, orderedItem:
if strings.HasPrefix(line, c.continuation) {
return len(c.continuation), true
}
return 0, false
}
panic("unreachable")
}
// Provides support for consuming a string line by line.
type lineSplitter struct {
text string
pos int
}
func (s *lineSplitter) more() bool {
return s.pos < len(s.text)
}
func (s *lineSplitter) next() string {
begin := s.pos
delta := strings.IndexByte(s.text[begin:], '\n')
if delta == -1 {
s.pos = len(s.text)
return s.text[begin:]
}
s.pos += delta + 1
return s.text[begin : s.pos-1]
}
func (s *lineSplitter) backup() {
if s.pos == 0 {
return
}
s.pos = 1 + strings.LastIndexByte(s.text[:s.pos-1], '\n')
}
var leftAnchoredCharRefRegexp = regexp.MustCompile(`^` + charRefPattern)
func leadingCharRef(s string) string {
return leftAnchoredCharRefRegexp.FindString(s)
}
elvish-0.20.1/pkg/md/mdrun/ 0000775 0000000 0000000 00000000000 14570151573 0015402 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/mdrun/main.go 0000664 0000000 0000000 00000002451 14570151573 0016657 0 ustar 00root root 0000000 0000000 // Command mdrun can be used to test the md package. Run it with "go run".
package main
import (
"flag"
"fmt"
"io"
"os"
"runtime/pprof"
"src.elv.sh/pkg/md"
)
var (
cpuprofile = flag.String("cpuprofile", "", "name of file to store CPU profile in")
codec = flag.String("codec", "html", "codec to use; one of html, trace, fmt, tty")
width = flag.Int("width", 0, "text width; relevant with fmt or tty")
)
func main() {
flag.Parse()
c := getCodec(*codec)
bs, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintln(os.Stderr, "read stdin:", err)
os.Exit(2)
}
if *cpuprofile != "" {
f, err := os.OpenFile(*cpuprofile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
fmt.Printf("create cpu profile file %q: %v\n", *cpuprofile, err)
os.Exit(2)
}
defer f.Close()
err = pprof.StartCPUProfile(f)
if err != nil {
fmt.Println("start cpu profile:", err)
os.Exit(2)
}
defer pprof.StopCPUProfile()
}
fmt.Print(md.RenderString(string(bs), c))
}
func getCodec(s string) md.StringerCodec {
switch *codec {
case "html":
return &md.HTMLCodec{}
case "trace":
return &md.TraceCodec{}
case "fmt":
return &md.FmtCodec{Width: *width}
case "tty":
return &md.TTYCodec{Width: *width}
default:
fmt.Println("unknown codec:", s)
os.Exit(2)
return nil
}
}
elvish-0.20.1/pkg/md/smart_puncts.go 0000664 0000000 0000000 00000003654 14570151573 0017336 0 ustar 00root root 0000000 0000000 package md
import (
"strings"
"unicode"
)
// SmartPunctsCodec wraps another codec, converting certain ASCII punctuations to
// nicer Unicode counterparts:
//
// - A straight double quote (") is converted to a left double quote (“) when
// it follows a whitespace, or a right double quote (”) when it follows a
// non-whitespace.
//
// - A straight single quote (') is converted to a left single quote (‘) when
// it follows a whitespace, or a right single quote or apostrophe (’) when
// it follows a non-whitespace.
//
// - A run of two dashes (--) is converted to an en-dash (–).
//
// - A run of three dashes (---) is converted to an em-dash (—).
//
// - A run of three dot (...) is converted to an ellipsis (…).
//
// Start of lines are considered to be whitespaces.
type SmartPunctsCodec struct{ Inner Codec }
func (c SmartPunctsCodec) Do(op Op) { c.Inner.Do(applySmartPunctsToOp(op)) }
func applySmartPunctsToOp(op Op) Op {
for i := range op.Content {
inlineOp := &op.Content[i]
switch inlineOp.Type {
case OpText, OpLinkStart, OpLinkEnd, OpImage:
inlineOp.Text = applySmartPuncts(inlineOp.Text)
if inlineOp.Type == OpImage {
inlineOp.Alt = applySmartPuncts(inlineOp.Alt)
}
}
}
return op
}
var applySimpleSmartPuncts = strings.NewReplacer(
"--", "–", "---", "—", "...", "…").Replace
func applySmartPuncts(s string) string {
return applySimpleSmartPuncts(applySmartQuotes(s))
}
func applySmartQuotes(s string) string {
if !strings.ContainsAny(s, `'"`) {
return s
}
var sb strings.Builder
// Start of line is considered to be whitespace
prev := ' '
for _, r := range s {
if r == '"' {
if unicode.IsSpace(prev) {
sb.WriteRune('“')
} else {
sb.WriteRune('”')
}
} else if r == '\'' {
if unicode.IsSpace(prev) {
sb.WriteRune('‘')
} else {
sb.WriteRune('’')
}
} else {
sb.WriteRune(r)
}
prev = r
}
return sb.String()
}
elvish-0.20.1/pkg/md/smart_puncts_test.go 0000664 0000000 0000000 00000002663 14570151573 0020374 0 ustar 00root root 0000000 0000000 package md_test
import (
"testing"
"github.com/google/go-cmp/cmp"
. "src.elv.sh/pkg/md"
)
var smartPunctsTestCases = []testCase{
{
Name: "Simple smart punctuations",
Markdown: `a -- b --- c...`,
HTML: dedent(`
a – b –- c…
`), }, { Name: "Smart quotes", Markdown: `It's "foo" and 'bar'.`, HTML: dedent(`It’s “foo” and ‘bar’.
`), }, { Name: "Link and image title", Markdown: dedent(` [link text](a.html "--")  `), HTML: dedent(` `), }, { Name: "Link alt", Markdown: ``, HTML: dedent(`a -- b
a -- b
`),
},
}
func TestSmartPuncts(t *testing.T) {
for _, tc := range smartPunctsTestCases {
t.Run(tc.Name, func(t *testing.T) {
var htmlCodec HTMLCodec
Render(tc.Markdown, SmartPunctsCodec{&htmlCodec})
got := htmlCodec.String()
if diff := cmp.Diff(tc.HTML, got); diff != "" {
t.Errorf("input:\n%s\ndiff (-want +got):\n%s",
hr+"\n"+tc.Markdown+hr, diff)
}
})
}
}
elvish-0.20.1/pkg/md/spec/ 0000775 0000000 0000000 00000000000 14570151573 0015207 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/spec/LICENSE 0000664 0000000 0000000 00000000352 14570151573 0016214 0 ustar 00root root 0000000 0000000 The spec tests (spec.json) are derived from the CommonMark spec (spec.txt),
which are
Copyright (C) 2014-16 John MacFarlane
Released under the Creative Commons CC-BY-SA 4.0 license:
foo\tbaz\t\tbim\n
\n",
"example": 1,
"start_line": 356,
"end_line": 361,
"section": "Tabs"
},
{
"markdown": " \tfoo\tbaz\t\tbim\n",
"html": "foo\tbaz\t\tbim\n
\n",
"example": 2,
"start_line": 363,
"end_line": 368,
"section": "Tabs"
},
{
"markdown": " a\ta\n ὐ\ta\n",
"html": "a\ta\nὐ\ta\n
\n",
"example": 3,
"start_line": 370,
"end_line": 377,
"section": "Tabs"
},
{
"markdown": " - foo\n\n\tbar\n",
"html": "foo
\nbar
\nfoo
\n bar\n
\n\n\n", "example": 6, "start_line": 419, "end_line": 426, "section": "Tabs" }, { "markdown": "-\t\tfoo\n", "html": "\nfoo\n
foo\n
\nfoo\nbar\n
\n",
"example": 8,
"start_line": 440,
"end_line": 447,
"section": "Tabs"
},
{
"markdown": " - foo\n - bar\n\t - baz\n",
"html": "!"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~
\n", "example": 12, "start_line": 490, "end_line": 494, "section": "Backslash escapes" }, { "markdown": "\\\t\\A\\a\\ \\3\\φ\\«\n", "html": "\\\t\\A\\a\\ \\3\\φ\\«
\n", "example": 13, "start_line": 500, "end_line": 504, "section": "Backslash escapes" }, { "markdown": "\\*not emphasized*\n\\*not emphasized*\n<br/> not a tag\n[not a link](/foo)\n`not code`\n1. not a list\n* not a list\n# not a heading\n[foo]: /url "not a reference"\nö not a character entity
\n", "example": 14, "start_line": 510, "end_line": 530, "section": "Backslash escapes" }, { "markdown": "\\\\*emphasis*\n", "html": "\\emphasis
\n", "example": 15, "start_line": 535, "end_line": 539, "section": "Backslash escapes" }, { "markdown": "foo\\\nbar\n", "html": "foo
\nbar
\\[\\`
\\[\\]\n
\n",
"example": 18,
"start_line": 563,
"end_line": 568,
"section": "Backslash escapes"
},
{
"markdown": "~~~\n\\[\\]\n~~~\n",
"html": "\\[\\]\n
\n",
"example": 19,
"start_line": 571,
"end_line": 578,
"section": "Backslash escapes"
},
{
"markdown": "foo\n
\n",
"example": 24,
"start_line": 614,
"end_line": 621,
"section": "Backslash escapes"
},
{
"markdown": " & © Æ Ď\n¾ ℋ ⅆ\n∲ ≧̸\n",
"html": "& © Æ Ď\n¾ ℋ ⅆ\n∲ ≧̸
\n", "example": 25, "start_line": 650, "end_line": 658, "section": "Entity and numeric character references" }, { "markdown": "# Ӓ Ϡ \n", "html": "# Ӓ Ϡ �
\n", "example": 26, "start_line": 669, "end_line": 673, "section": "Entity and numeric character references" }, { "markdown": "" ആ ಫ\n", "html": "" ആ ಫ
\n", "example": 27, "start_line": 682, "end_line": 686, "section": "Entity and numeric character references" }, { "markdown": "  &x; \n\nabcdef0;\n&ThisIsNotDefined; &hi?;\n", "html": "  &x; &#; &#x;\n�\n&#abcdef0;\n&ThisIsNotDefined; &hi?;
\n", "example": 28, "start_line": 691, "end_line": 701, "section": "Entity and numeric character references" }, { "markdown": "©\n", "html": "©
\n", "example": 29, "start_line": 708, "end_line": 712, "section": "Entity and numeric character references" }, { "markdown": "&MadeUpEntity;\n", "html": "&MadeUpEntity;
\n", "example": 30, "start_line": 718, "end_line": 722, "section": "Entity and numeric character references" }, { "markdown": "\n", "html": "\n", "example": 31, "start_line": 729, "end_line": 733, "section": "Entity and numeric character references" }, { "markdown": "[foo](/föö \"föö\")\n", "html": "\n", "example": 32, "start_line": 736, "end_line": 740, "section": "Entity and numeric character references" }, { "markdown": "[foo]\n\n[foo]: /föö \"föö\"\n", "html": "\n", "example": 33, "start_line": 743, "end_line": 749, "section": "Entity and numeric character references" }, { "markdown": "``` föö\nfoo\n```\n", "html": "foo\n
\n",
"example": 34,
"start_line": 752,
"end_line": 759,
"section": "Entity and numeric character references"
},
{
"markdown": "`föö`\n",
"html": "föö
föfö\n
\n",
"example": 36,
"start_line": 772,
"end_line": 777,
"section": "Entity and numeric character references"
},
{
"markdown": "*foo*\n*foo*\n",
"html": "*foo*\nfoo
\n", "example": 37, "start_line": 784, "end_line": 790, "section": "Entity and numeric character references" }, { "markdown": "* foo\n\n* foo\n", "html": "* foo
\nfoo\n\nbar
\n", "example": 39, "start_line": 803, "end_line": 809, "section": "Entity and numeric character references" }, { "markdown": " foo\n", "html": "\tfoo
\n", "example": 40, "start_line": 811, "end_line": 815, "section": "Entity and numeric character references" }, { "markdown": "[a](url "tit")\n", "html": "[a](url "tit")
\n", "example": 41, "start_line": 818, "end_line": 822, "section": "Entity and numeric character references" }, { "markdown": "- `one\n- two`\n", "html": "+++
\n", "example": 44, "start_line": 893, "end_line": 897, "section": "Thematic breaks" }, { "markdown": "===\n", "html": "===
\n", "example": 45, "start_line": 900, "end_line": 904, "section": "Thematic breaks" }, { "markdown": "--\n**\n__\n", "html": "--\n**\n__
\n", "example": 46, "start_line": 909, "end_line": 917, "section": "Thematic breaks" }, { "markdown": " ***\n ***\n ***\n", "html": "***\n
\n",
"example": 48,
"start_line": 935,
"end_line": 940,
"section": "Thematic breaks"
},
{
"markdown": "Foo\n ***\n",
"html": "Foo\n***
\n", "example": 49, "start_line": 943, "end_line": 949, "section": "Thematic breaks" }, { "markdown": "_____________________________________\n", "html": "_ _ _ _ a
\na------
\n---a---
\n", "example": 55, "start_line": 995, "end_line": 1005, "section": "Thematic breaks" }, { "markdown": " *-*\n", "html": "-
\n", "example": 56, "start_line": 1011, "end_line": 1015, "section": "Thematic breaks" }, { "markdown": "- foo\n***\n- bar\n", "html": "Foo
\nbar
\n", "example": 58, "start_line": 1037, "end_line": 1045, "section": "Thematic breaks" }, { "markdown": "Foo\n---\nbar\n", "html": "bar
\n", "example": 59, "start_line": 1054, "end_line": 1061, "section": "Thematic breaks" }, { "markdown": "* Foo\n* * *\n* Bar\n", "html": "####### foo
\n", "example": 63, "start_line": 1132, "end_line": 1136, "section": "ATX headings" }, { "markdown": "#5 bolt\n\n#hashtag\n", "html": "#5 bolt
\n#hashtag
\n", "example": 64, "start_line": 1147, "end_line": 1154, "section": "ATX headings" }, { "markdown": "\\## foo\n", "html": "## foo
\n", "example": 65, "start_line": 1159, "end_line": 1163, "section": "ATX headings" }, { "markdown": "# foo *bar* \\*baz\\*\n", "html": "# foo\n
\n",
"example": 69,
"start_line": 1199,
"end_line": 1204,
"section": "ATX headings"
},
{
"markdown": "foo\n # bar\n",
"html": "foo\n# bar
\n", "example": 70, "start_line": 1207, "end_line": 1213, "section": "ATX headings" }, { "markdown": "## foo ##\n ### bar ###\n", "html": "Foo bar
\nBar foo
\n", "example": 78, "start_line": 1295, "end_line": 1303, "section": "ATX headings" }, { "markdown": "## \n#\n### ###\n", "html": "\n\n\n", "example": 79, "start_line": 1308, "end_line": 1316, "section": "ATX headings" }, { "markdown": "Foo *bar*\n=========\n\nFoo *bar*\n---------\n", "html": "Foo\n---\n\nFoo\n
\nFoo\n---
\n", "example": 87, "start_line": 1450, "end_line": 1456, "section": "Setext headings" }, { "markdown": "Foo\n= =\n\nFoo\n--- -\n", "html": "Foo\n= =
\nFoo
\n`
\nof dashes"/>
\n", "example": 91, "start_line": 1498, "end_line": 1511, "section": "Setext headings" }, { "markdown": "> Foo\n---\n", "html": "\n\nFoo
\n
\n\n", "example": 93, "start_line": 1528, "end_line": 1538, "section": "Setext headings" }, { "markdown": "- Foo\n---\n", "html": "foo\nbar\n===
\n
Baz
\n", "example": 96, "start_line": 1569, "end_line": 1581, "section": "Setext headings" }, { "markdown": "\n====\n", "html": "====
\n", "example": 97, "start_line": 1586, "end_line": 1591, "section": "Setext headings" }, { "markdown": "---\n---\n", "html": "foo\n
\n\n\nfoo
\n
Foo
\nbaz
\n", "example": 103, "start_line": 1673, "end_line": 1683, "section": "Setext headings" }, { "markdown": "Foo\nbar\n\n---\n\nbaz\n", "html": "Foo\nbar
\nbaz
\n", "example": 104, "start_line": 1689, "end_line": 1701, "section": "Setext headings" }, { "markdown": "Foo\nbar\n* * *\nbaz\n", "html": "Foo\nbar
\nbaz
\n", "example": 105, "start_line": 1707, "end_line": 1717, "section": "Setext headings" }, { "markdown": "Foo\nbar\n\\---\nbaz\n", "html": "Foo\nbar\n---\nbaz
\n", "example": 106, "start_line": 1722, "end_line": 1732, "section": "Setext headings" }, { "markdown": " a simple\n indented code block\n", "html": "a simple\n indented code block\n
\n",
"example": 107,
"start_line": 1750,
"end_line": 1757,
"section": "Indented code blocks"
},
{
"markdown": " - foo\n\n bar\n",
"html": "foo
\nbar
\nfoo
\n<a/>\n*hi*\n\n- one\n
\n",
"example": 110,
"start_line": 1798,
"end_line": 1809,
"section": "Indented code blocks"
},
{
"markdown": " chunk1\n\n chunk2\n \n \n \n chunk3\n",
"html": "chunk1\n\nchunk2\n\n\n\nchunk3\n
\n",
"example": 111,
"start_line": 1814,
"end_line": 1831,
"section": "Indented code blocks"
},
{
"markdown": " chunk1\n \n chunk2\n",
"html": "chunk1\n \n chunk2\n
\n",
"example": 112,
"start_line": 1837,
"end_line": 1846,
"section": "Indented code blocks"
},
{
"markdown": "Foo\n bar\n\n",
"html": "Foo\nbar
\n", "example": 113, "start_line": 1852, "end_line": 1859, "section": "Indented code blocks" }, { "markdown": " foo\nbar\n", "html": "foo\n
\nbar
\n", "example": 114, "start_line": 1866, "end_line": 1873, "section": "Indented code blocks" }, { "markdown": "# Heading\n foo\nHeading\n------\n foo\n----\n", "html": "foo\n
\nfoo\n
\n foo\nbar\n
\n",
"example": 116,
"start_line": 1899,
"end_line": 1906,
"section": "Indented code blocks"
},
{
"markdown": "\n \n foo\n \n\n",
"html": "foo\n
\n",
"example": 117,
"start_line": 1912,
"end_line": 1921,
"section": "Indented code blocks"
},
{
"markdown": " foo \n",
"html": "foo \n
\n",
"example": 118,
"start_line": 1926,
"end_line": 1931,
"section": "Indented code blocks"
},
{
"markdown": "```\n<\n >\n```\n",
"html": "<\n >\n
\n",
"example": 119,
"start_line": 1981,
"end_line": 1990,
"section": "Fenced code blocks"
},
{
"markdown": "~~~\n<\n >\n~~~\n",
"html": "<\n >\n
\n",
"example": 120,
"start_line": 1995,
"end_line": 2004,
"section": "Fenced code blocks"
},
{
"markdown": "``\nfoo\n``\n",
"html": "foo
aaa\n~~~\n
\n",
"example": 122,
"start_line": 2019,
"end_line": 2028,
"section": "Fenced code blocks"
},
{
"markdown": "~~~\naaa\n```\n~~~\n",
"html": "aaa\n```\n
\n",
"example": 123,
"start_line": 2031,
"end_line": 2040,
"section": "Fenced code blocks"
},
{
"markdown": "````\naaa\n```\n``````\n",
"html": "aaa\n```\n
\n",
"example": 124,
"start_line": 2045,
"end_line": 2054,
"section": "Fenced code blocks"
},
{
"markdown": "~~~~\naaa\n~~~\n~~~~\n",
"html": "aaa\n~~~\n
\n",
"example": 125,
"start_line": 2057,
"end_line": 2066,
"section": "Fenced code blocks"
},
{
"markdown": "```\n",
"html": "
\n",
"example": 126,
"start_line": 2072,
"end_line": 2076,
"section": "Fenced code blocks"
},
{
"markdown": "`````\n\n```\naaa\n",
"html": "\n```\naaa\n
\n",
"example": 127,
"start_line": 2079,
"end_line": 2089,
"section": "Fenced code blocks"
},
{
"markdown": "> ```\n> aaa\n\nbbb\n",
"html": "\n\n\naaa\n
bbb
\n", "example": 128, "start_line": 2092, "end_line": 2103, "section": "Fenced code blocks" }, { "markdown": "```\n\n \n```\n", "html": "\n \n
\n",
"example": 129,
"start_line": 2108,
"end_line": 2117,
"section": "Fenced code blocks"
},
{
"markdown": "```\n```\n",
"html": "
\n",
"example": 130,
"start_line": 2122,
"end_line": 2127,
"section": "Fenced code blocks"
},
{
"markdown": " ```\n aaa\naaa\n```\n",
"html": "aaa\naaa\n
\n",
"example": 131,
"start_line": 2134,
"end_line": 2143,
"section": "Fenced code blocks"
},
{
"markdown": " ```\naaa\n aaa\naaa\n ```\n",
"html": "aaa\naaa\naaa\n
\n",
"example": 132,
"start_line": 2146,
"end_line": 2157,
"section": "Fenced code blocks"
},
{
"markdown": " ```\n aaa\n aaa\n aaa\n ```\n",
"html": "aaa\n aaa\naaa\n
\n",
"example": 133,
"start_line": 2160,
"end_line": 2171,
"section": "Fenced code blocks"
},
{
"markdown": " ```\n aaa\n ```\n",
"html": "```\naaa\n```\n
\n",
"example": 134,
"start_line": 2176,
"end_line": 2185,
"section": "Fenced code blocks"
},
{
"markdown": "```\naaa\n ```\n",
"html": "aaa\n
\n",
"example": 135,
"start_line": 2191,
"end_line": 2198,
"section": "Fenced code blocks"
},
{
"markdown": " ```\naaa\n ```\n",
"html": "aaa\n
\n",
"example": 136,
"start_line": 2201,
"end_line": 2208,
"section": "Fenced code blocks"
},
{
"markdown": "```\naaa\n ```\n",
"html": "aaa\n ```\n
\n",
"example": 137,
"start_line": 2213,
"end_line": 2221,
"section": "Fenced code blocks"
},
{
"markdown": "``` ```\naaa\n",
"html": "
\naaa
aaa\n~~~ ~~\n
\n",
"example": 139,
"start_line": 2236,
"end_line": 2244,
"section": "Fenced code blocks"
},
{
"markdown": "foo\n```\nbar\n```\nbaz\n",
"html": "foo
\nbar\n
\nbaz
\n", "example": 140, "start_line": 2250, "end_line": 2261, "section": "Fenced code blocks" }, { "markdown": "foo\n---\n~~~\nbar\n~~~\n# baz\n", "html": "bar\n
\ndef foo(x)\n return 3\nend\n
\n",
"example": 142,
"start_line": 2289,
"end_line": 2300,
"section": "Fenced code blocks"
},
{
"markdown": "~~~~ ruby startline=3 $%@#$\ndef foo(x)\n return 3\nend\n~~~~~~~\n",
"html": "def foo(x)\n return 3\nend\n
\n",
"example": 143,
"start_line": 2303,
"end_line": 2314,
"section": "Fenced code blocks"
},
{
"markdown": "````;\n````\n",
"html": "
\n",
"example": 144,
"start_line": 2317,
"end_line": 2322,
"section": "Fenced code blocks"
},
{
"markdown": "``` aa ```\nfoo\n",
"html": "aa
\nfoo
foo\n
\n",
"example": 146,
"start_line": 2338,
"end_line": 2345,
"section": "Fenced code blocks"
},
{
"markdown": "```\n``` aaa\n```\n",
"html": "``` aaa\n
\n",
"example": 147,
"start_line": 2350,
"end_line": 2357,
"section": "Fenced code blocks"
},
{
"markdown": "\n\n**Hello**,\n\n_world_.\n\n |
\n\n**Hello**,\n\n |
\n hi\n | \n
\n hi\n | \n
okay.
\n", "example": 149, "start_line": 2458, "end_line": 2477, "section": "HTML blocks" }, { "markdown": "Markdown
\nbar
\n", "example": 155, "start_line": 2543, "end_line": 2552, "section": "HTML blocks" }, { "markdown": "\n", "html": "\n", "example": 159, "start_line": 2592, "end_line": 2596, "section": "HTML blocks" }, { "markdown": "\nfoo\n |
\nfoo\n |
foo
\nfoo
\nimport Text.HTML.TagSoup\n\nmain :: IO ()\nmain = print $ parseTags tags\n
\nokay\n",
"html": "\nimport Text.HTML.TagSoup\n\nmain :: IO ()\nmain = print $ parseTags tags\n
\nokay
\n", "example": 169, "start_line": 2732, "end_line": 2748, "section": "HTML blocks" }, { "markdown": "\nokay\n", "html": "\nokay
\n", "example": 170, "start_line": 2753, "end_line": 2767, "section": "HTML blocks" }, { "markdown": "\n", "html": "\n", "example": 171, "start_line": 2772, "end_line": 2788, "section": "HTML blocks" }, { "markdown": "\nokay\n", "html": "\nokay
\n", "example": 172, "start_line": 2792, "end_line": 2808, "section": "HTML blocks" }, { "markdown": "\n*foo*\n", "html": "\nfoo
\n", "example": 176, "start_line": 2857, "end_line": 2863, "section": "HTML blocks" }, { "markdown": "*bar*\n*baz*\n", "html": "*bar*\nbaz
\n", "example": 177, "start_line": 2866, "end_line": 2872, "section": "HTML blocks" }, { "markdown": "1. *bar*\n", "html": "1. *bar*\n", "example": 178, "start_line": 2878, "end_line": 2886, "section": "HTML blocks" }, { "markdown": "\nokay\n", "html": "\nokay
\n", "example": 179, "start_line": 2891, "end_line": 2903, "section": "HTML blocks" }, { "markdown": "';\n\n?>\nokay\n", "html": "';\n\n?>\nokay
\n", "example": 180, "start_line": 2909, "end_line": 2923, "section": "HTML blocks" }, { "markdown": "\n", "html": "\n", "example": 181, "start_line": 2928, "end_line": 2932, "section": "HTML blocks" }, { "markdown": "\nokay\n", "html": "\nokay
\n", "example": 182, "start_line": 2937, "end_line": 2965, "section": "HTML blocks" }, { "markdown": " \n\n \n", "html": " \n<!-- foo -->\n
\n",
"example": 183,
"start_line": 2971,
"end_line": 2979,
"section": "HTML blocks"
},
{
"markdown": " <div>\n
\n",
"example": 184,
"start_line": 2982,
"end_line": 2990,
"section": "HTML blocks"
},
{
"markdown": "Foo\nFoo
\nFoo\n\nbaz
\n", "example": 187, "start_line": 3028, "end_line": 3036, "section": "HTML blocks" }, { "markdown": "Emphasized text.
\n\nHi\n | \n\n
\nHi\n | \n
\n Hi\n | \n\n
[foo]: /url 'title
\nwith blank line'
\n[foo]
\n", "example": 197, "start_line": 3241, "end_line": 3251, "section": "Link reference definitions" }, { "markdown": "[foo]:\n/url\n\n[foo]\n", "html": "\n", "example": 198, "start_line": 3256, "end_line": 3263, "section": "Link reference definitions" }, { "markdown": "[foo]:\n\n[foo]\n", "html": "[foo]:
\n[foo]
\n", "example": 199, "start_line": 3268, "end_line": 3275, "section": "Link reference definitions" }, { "markdown": "[foo]: <>\n\n[foo]\n", "html": "\n", "example": 200, "start_line": 3280, "end_line": 3286, "section": "Link reference definitions" }, { "markdown": "[foo]:[foo]:
[foo]
\n", "example": 201, "start_line": 3291, "end_line": 3298, "section": "Link reference definitions" }, { "markdown": "[foo]: /url\\bar\\*baz \"foo\\\"bar\\baz\"\n\n[foo]\n", "html": "\n", "example": 202, "start_line": 3304, "end_line": 3310, "section": "Link reference definitions" }, { "markdown": "[foo]\n\n[foo]: url\n", "html": "\n", "example": 203, "start_line": 3315, "end_line": 3321, "section": "Link reference definitions" }, { "markdown": "[foo]\n\n[foo]: first\n[foo]: second\n", "html": "\n", "example": 204, "start_line": 3327, "end_line": 3334, "section": "Link reference definitions" }, { "markdown": "[FOO]: /url\n\n[Foo]\n", "html": "\n", "example": 205, "start_line": 3340, "end_line": 3346, "section": "Link reference definitions" }, { "markdown": "[ΑΓΩ]: /φου\n\n[αγω]\n", "html": "\n", "example": 206, "start_line": 3349, "end_line": 3355, "section": "Link reference definitions" }, { "markdown": "[foo]: /url\n", "html": "", "example": 207, "start_line": 3364, "end_line": 3367, "section": "Link reference definitions" }, { "markdown": "[\nfoo\n]: /url\nbar\n", "html": "bar
\n", "example": 208, "start_line": 3372, "end_line": 3379, "section": "Link reference definitions" }, { "markdown": "[foo]: /url \"title\" ok\n", "html": "[foo]: /url "title" ok
\n", "example": 209, "start_line": 3385, "end_line": 3389, "section": "Link reference definitions" }, { "markdown": "[foo]: /url\n\"title\" ok\n", "html": ""title" ok
\n", "example": 210, "start_line": 3394, "end_line": 3399, "section": "Link reference definitions" }, { "markdown": " [foo]: /url \"title\"\n\n[foo]\n", "html": "[foo]: /url "title"\n
\n[foo]
\n", "example": 211, "start_line": 3405, "end_line": 3413, "section": "Link reference definitions" }, { "markdown": "```\n[foo]: /url\n```\n\n[foo]\n", "html": "[foo]: /url\n
\n[foo]
\n", "example": 212, "start_line": 3419, "end_line": 3429, "section": "Link reference definitions" }, { "markdown": "Foo\n[bar]: /baz\n\n[bar]\n", "html": "Foo\n[bar]: /baz
\n[bar]
\n", "example": 213, "start_line": 3434, "end_line": 3443, "section": "Link reference definitions" }, { "markdown": "# [Foo]\n[foo]: /url\n> bar\n", "html": "\n\n", "example": 214, "start_line": 3449, "end_line": 3458, "section": "Link reference definitions" }, { "markdown": "[foo]: /url\nbar\n===\n[foo]\n", "html": "bar
\n
===\nfoo
\n", "example": 216, "start_line": 3470, "end_line": 3477, "section": "Link reference definitions" }, { "markdown": "[foo]: /foo-url \"foo\"\n[bar]: /bar-url\n \"bar\"\n[baz]: /baz-url\n\n[foo],\n[bar],\n[baz]\n", "html": "\n", "example": 217, "start_line": 3483, "end_line": 3496, "section": "Link reference definitions" }, { "markdown": "[foo]\n\n> [foo]: /url\n", "html": "\n\n\n", "example": 218, "start_line": 3504, "end_line": 3512, "section": "Link reference definitions" }, { "markdown": "aaa\n\nbbb\n", "html": "
aaa
\nbbb
\n", "example": 219, "start_line": 3526, "end_line": 3533, "section": "Paragraphs" }, { "markdown": "aaa\nbbb\n\nccc\nddd\n", "html": "aaa\nbbb
\nccc\nddd
\n", "example": 220, "start_line": 3538, "end_line": 3549, "section": "Paragraphs" }, { "markdown": "aaa\n\n\nbbb\n", "html": "aaa
\nbbb
\n", "example": 221, "start_line": 3554, "end_line": 3562, "section": "Paragraphs" }, { "markdown": " aaa\n bbb\n", "html": "aaa\nbbb
\n", "example": 222, "start_line": 3567, "end_line": 3573, "section": "Paragraphs" }, { "markdown": "aaa\n bbb\n ccc\n", "html": "aaa\nbbb\nccc
\n", "example": 223, "start_line": 3579, "end_line": 3587, "section": "Paragraphs" }, { "markdown": " aaa\nbbb\n", "html": "aaa\nbbb
\n", "example": 224, "start_line": 3593, "end_line": 3599, "section": "Paragraphs" }, { "markdown": " aaa\nbbb\n", "html": "aaa\n
\nbbb
\n", "example": 225, "start_line": 3602, "end_line": 3609, "section": "Paragraphs" }, { "markdown": "aaa \nbbb \n", "html": "aaa
\nbbb
aaa
\n\n\n", "example": 228, "start_line": 3701, "end_line": 3711, "section": "Block quotes" }, { "markdown": "># Foo\n>bar\n> baz\n", "html": "Foo
\nbar\nbaz
\n
\n\n", "example": 229, "start_line": 3716, "end_line": 3726, "section": "Block quotes" }, { "markdown": " > # Foo\n > bar\n > baz\n", "html": "Foo
\nbar\nbaz
\n
\n\n", "example": 230, "start_line": 3731, "end_line": 3741, "section": "Block quotes" }, { "markdown": " > # Foo\n > bar\n > baz\n", "html": "Foo
\nbar\nbaz
\n
> # Foo\n> bar\n> baz\n
\n",
"example": 231,
"start_line": 3746,
"end_line": 3755,
"section": "Block quotes"
},
{
"markdown": "> # Foo\n> bar\nbaz\n",
"html": "\n\n", "example": 232, "start_line": 3761, "end_line": 3771, "section": "Block quotes" }, { "markdown": "> bar\nbaz\n> foo\n", "html": "Foo
\nbar\nbaz
\n
\n\n", "example": 233, "start_line": 3777, "end_line": 3787, "section": "Block quotes" }, { "markdown": "> foo\n---\n", "html": "bar\nbaz\nfoo
\n
\n\nfoo
\n
\n\n\n
\n- foo
\n
\n\n\nfoo\n
bar\n
\n",
"example": 236,
"start_line": 3839,
"end_line": 3849,
"section": "Block quotes"
},
{
"markdown": "> ```\nfoo\n```\n",
"html": "\n\n\n
foo
\n
\n",
"example": 237,
"start_line": 3852,
"end_line": 3862,
"section": "Block quotes"
},
{
"markdown": "> foo\n - bar\n",
"html": "\n\n", "example": 238, "start_line": 3868, "end_line": 3876, "section": "Block quotes" }, { "markdown": ">\n", "html": "foo\n- bar
\n
\n\n", "example": 239, "start_line": 3892, "end_line": 3897, "section": "Block quotes" }, { "markdown": ">\n> \n> \n", "html": "
\n\n", "example": 240, "start_line": 3900, "end_line": 3907, "section": "Block quotes" }, { "markdown": ">\n> foo\n> \n", "html": "
\n\n", "example": 241, "start_line": 3912, "end_line": 3920, "section": "Block quotes" }, { "markdown": "> foo\n\n> bar\n", "html": "foo
\n
\n\nfoo
\n
\n\n", "example": 242, "start_line": 3925, "end_line": 3936, "section": "Block quotes" }, { "markdown": "> foo\n> bar\n", "html": "bar
\n
\n\n", "example": 243, "start_line": 3947, "end_line": 3955, "section": "Block quotes" }, { "markdown": "> foo\n>\n> bar\n", "html": "foo\nbar
\n
\n\n", "example": 244, "start_line": 3960, "end_line": 3969, "section": "Block quotes" }, { "markdown": "foo\n> bar\n", "html": "foo
\nbar
\n
foo
\n\n\n", "example": 245, "start_line": 3974, "end_line": 3982, "section": "Block quotes" }, { "markdown": "> aaa\n***\n> bbb\n", "html": "bar
\n
\n\naaa
\n
\n\n", "example": 246, "start_line": 3988, "end_line": 4000, "section": "Block quotes" }, { "markdown": "> bar\nbaz\n", "html": "bbb
\n
\n\n", "example": 247, "start_line": 4006, "end_line": 4014, "section": "Block quotes" }, { "markdown": "> bar\n\nbaz\n", "html": "bar\nbaz
\n
\n\nbar
\n
baz
\n", "example": 248, "start_line": 4017, "end_line": 4026, "section": "Block quotes" }, { "markdown": "> bar\n>\nbaz\n", "html": "\n\nbar
\n
baz
\n", "example": 249, "start_line": 4029, "end_line": 4038, "section": "Block quotes" }, { "markdown": "> > > foo\nbar\n", "html": "\n\n", "example": 250, "start_line": 4045, "end_line": 4057, "section": "Block quotes" }, { "markdown": ">>> foo\n> bar\n>>baz\n", "html": "\n\n\n\nfoo\nbar
\n
\n\n", "example": 251, "start_line": 4060, "end_line": 4074, "section": "Block quotes" }, { "markdown": "> code\n\n> not code\n", "html": "\n\n\n\nfoo\nbar\nbaz
\n
\n\n\ncode\n
\n\n", "example": 252, "start_line": 4082, "end_line": 4094, "section": "Block quotes" }, { "markdown": "A paragraph\nwith two lines.\n\n indented code\n\n> A block quote.\n", "html": "not code
\n
A paragraph\nwith two lines.
\nindented code\n
\n\n\n", "example": 253, "start_line": 4136, "end_line": 4151, "section": "List items" }, { "markdown": "1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote.\n", "html": "A block quote.
\n
A paragraph\nwith two lines.
\nindented code\n
\n\n\nA block quote.
\n
two
\n", "example": 255, "start_line": 4191, "end_line": 4200, "section": "List items" }, { "markdown": "- one\n\n two\n", "html": "one
\ntwo
\n two\n
\n",
"example": 257,
"start_line": 4217,
"end_line": 4227,
"section": "List items"
},
{
"markdown": " - one\n\n two\n",
"html": "one
\ntwo
\n\n\n", "example": 259, "start_line": 4252, "end_line": 4267, "section": "List items" }, { "markdown": ">>- one\n>>\n > > two\n", "html": "\n\n\n
\n- \n
\none
\ntwo
\n
\n\n", "example": 260, "start_line": 4279, "end_line": 4292, "section": "List items" }, { "markdown": "-one\n\n2.two\n", "html": "\n\n\n
\n- one
\ntwo
\n
-one
\n2.two
\n", "example": 261, "start_line": 4298, "end_line": 4305, "section": "List items" }, { "markdown": "- foo\n\n\n bar\n", "html": "foo
\nbar
\nfoo
\nbar\n
\nbaz
\n\n\nbam
\n
Foo
\nbar\n\n\nbaz\n
\n1234567890. not ok
\n", "example": 266, "start_line": 4387, "end_line": 4391, "section": "List items" }, { "markdown": "0. ok\n", "html": "-1. not ok
\n", "example": 269, "start_line": 4416, "end_line": 4420, "section": "List items" }, { "markdown": "- foo\n\n bar\n", "html": "foo
\nbar\n
\nfoo
\nbar\n
\nindented code\n
\nparagraph
\nmore code\n
\n",
"example": 272,
"start_line": 4475,
"end_line": 4487,
"section": "List items"
},
{
"markdown": "1. indented code\n\n paragraph\n\n more code\n",
"html": "indented code\n
\nparagraph
\nmore code\n
\n indented code\n
\nparagraph
\nmore code\n
\nfoo
\nbar
\n", "example": 275, "start_line": 4539, "end_line": 4546, "section": "List items" }, { "markdown": "- foo\n\n bar\n", "html": "bar
\n", "example": 276, "start_line": 4549, "end_line": 4558, "section": "List items" }, { "markdown": "- foo\n\n bar\n", "html": "foo
\nbar
\nbar\n
\nbaz\n
\nfoo
\n", "example": 280, "start_line": 4633, "end_line": 4642, "section": "List items" }, { "markdown": "- foo\n-\n- bar\n", "html": "foo\n*
\nfoo\n1.
\n", "example": 285, "start_line": 4702, "end_line": 4713, "section": "List items" }, { "markdown": " 1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote.\n", "html": "A paragraph\nwith two lines.
\nindented code\n
\n\n\nA block quote.
\n
A paragraph\nwith two lines.
\nindented code\n
\n\n\nA block quote.
\n
A paragraph\nwith two lines.
\nindented code\n
\n\n\nA block quote.
\n
1. A paragraph\n with two lines.\n\n indented code\n\n > A block quote.\n
\n",
"example": 289,
"start_line": 4796,
"end_line": 4811,
"section": "List items"
},
{
"markdown": " 1. A paragraph\nwith two lines.\n\n indented code\n\n > A block quote.\n",
"html": "A paragraph\nwith two lines.
\nindented code\n
\n\n\nA block quote.
\n
\n\n", "example": 292, "start_line": 4863, "end_line": 4877, "section": "List items" }, { "markdown": "> 1. > Blockquote\n> continued here.\n", "html": "\n
\n- \n
\n\n\nBlockquote\ncontinued here.
\n
\n\n", "example": 293, "start_line": 4880, "end_line": 4894, "section": "List items" }, { "markdown": "- foo\n - bar\n - baz\n - boo\n", "html": "\n
\n- \n
\n\n\nBlockquote\ncontinued here.
\n
Foo
\nThe number of windows in my house is\n14. The number of doors is 6.
\n", "example": 304, "start_line": 5361, "end_line": 5367, "section": "Lists" }, { "markdown": "The number of windows in my house is\n1. The number of doors is 6.\n", "html": "The number of windows in my house is
\nfoo
\nbar
\nbaz
\nbaz
\nbim
\nfoo
\nnotcode
\nfoo
\ncode\n
\n",
"example": 309,
"start_line": 5457,
"end_line": 5480,
"section": "Lists"
},
{
"markdown": "- a\n - b\n - c\n - d\n - e\n - f\n- g\n",
"html": "a
\nb
\nc
\na
\nb
\n3. c\n
\n",
"example": 313,
"start_line": 5553,
"end_line": 5570,
"section": "Lists"
},
{
"markdown": "- a\n- b\n\n- c\n",
"html": "a
\nb
\nc
\na
\nc
\na
\nb
\nc
\nd
\na
\nb
\nd
\nb\n\n\n
\nb
\nc
\n\n\nb
\n
\n\nb
\n
c\n
\nfoo\n
\nbar
\nfoo
\nbaz
\na
\nd
\nhi
lo`
foo
foo ` bar
``
``
a
b
\n
foo bar baz
foo
foo bar baz
foo\\
bar`
foo`bar
foo `` bar
*foo*
[not a link](/foo
)
<a href="
">`
<http://foo.bar.
baz>`
```foo``
\n", "example": 347, "start_line": 6076, "end_line": 6080, "section": "Code spans" }, { "markdown": "`foo\n", "html": "`foo
\n", "example": 348, "start_line": 6083, "end_line": 6087, "section": "Code spans" }, { "markdown": "`foo``bar``\n", "html": "`foobar
foo bar
\n", "example": 350, "start_line": 6309, "end_line": 6313, "section": "Emphasis and strong emphasis" }, { "markdown": "a * foo bar*\n", "html": "a * foo bar*
\n", "example": 351, "start_line": 6319, "end_line": 6323, "section": "Emphasis and strong emphasis" }, { "markdown": "a*\"foo\"*\n", "html": "a*"foo"*
\n", "example": 352, "start_line": 6330, "end_line": 6334, "section": "Emphasis and strong emphasis" }, { "markdown": "* a *\n", "html": "* a *
\n", "example": 353, "start_line": 6339, "end_line": 6343, "section": "Emphasis and strong emphasis" }, { "markdown": "foo*bar*\n", "html": "foobar
\n", "example": 354, "start_line": 6348, "end_line": 6352, "section": "Emphasis and strong emphasis" }, { "markdown": "5*6*78\n", "html": "5678
\n", "example": 355, "start_line": 6355, "end_line": 6359, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo bar_\n", "html": "foo bar
\n", "example": 356, "start_line": 6364, "end_line": 6368, "section": "Emphasis and strong emphasis" }, { "markdown": "_ foo bar_\n", "html": "_ foo bar_
\n", "example": 357, "start_line": 6374, "end_line": 6378, "section": "Emphasis and strong emphasis" }, { "markdown": "a_\"foo\"_\n", "html": "a_"foo"_
\n", "example": 358, "start_line": 6384, "end_line": 6388, "section": "Emphasis and strong emphasis" }, { "markdown": "foo_bar_\n", "html": "foo_bar_
\n", "example": 359, "start_line": 6393, "end_line": 6397, "section": "Emphasis and strong emphasis" }, { "markdown": "5_6_78\n", "html": "5_6_78
\n", "example": 360, "start_line": 6400, "end_line": 6404, "section": "Emphasis and strong emphasis" }, { "markdown": "пристаням_стремятся_\n", "html": "пристаням_стремятся_
\n", "example": 361, "start_line": 6407, "end_line": 6411, "section": "Emphasis and strong emphasis" }, { "markdown": "aa_\"bb\"_cc\n", "html": "aa_"bb"_cc
\n", "example": 362, "start_line": 6417, "end_line": 6421, "section": "Emphasis and strong emphasis" }, { "markdown": "foo-_(bar)_\n", "html": "foo-(bar)
\n", "example": 363, "start_line": 6428, "end_line": 6432, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo*\n", "html": "_foo*
\n", "example": 364, "start_line": 6440, "end_line": 6444, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo bar *\n", "html": "*foo bar *
\n", "example": 365, "start_line": 6450, "end_line": 6454, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo bar\n*\n", "html": "*foo bar\n*
\n", "example": 366, "start_line": 6459, "end_line": 6465, "section": "Emphasis and strong emphasis" }, { "markdown": "*(*foo)\n", "html": "*(*foo)
\n", "example": 367, "start_line": 6472, "end_line": 6476, "section": "Emphasis and strong emphasis" }, { "markdown": "*(*foo*)*\n", "html": "(foo)
\n", "example": 368, "start_line": 6482, "end_line": 6486, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo*bar\n", "html": "foobar
\n", "example": 369, "start_line": 6491, "end_line": 6495, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo bar _\n", "html": "_foo bar _
\n", "example": 370, "start_line": 6504, "end_line": 6508, "section": "Emphasis and strong emphasis" }, { "markdown": "_(_foo)\n", "html": "_(_foo)
\n", "example": 371, "start_line": 6514, "end_line": 6518, "section": "Emphasis and strong emphasis" }, { "markdown": "_(_foo_)_\n", "html": "(foo)
\n", "example": 372, "start_line": 6523, "end_line": 6527, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo_bar\n", "html": "_foo_bar
\n", "example": 373, "start_line": 6532, "end_line": 6536, "section": "Emphasis and strong emphasis" }, { "markdown": "_пристаням_стремятся\n", "html": "_пристаням_стремятся
\n", "example": 374, "start_line": 6539, "end_line": 6543, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo_bar_baz_\n", "html": "foo_bar_baz
\n", "example": 375, "start_line": 6546, "end_line": 6550, "section": "Emphasis and strong emphasis" }, { "markdown": "_(bar)_.\n", "html": "(bar).
\n", "example": 376, "start_line": 6557, "end_line": 6561, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo bar**\n", "html": "foo bar
\n", "example": 377, "start_line": 6566, "end_line": 6570, "section": "Emphasis and strong emphasis" }, { "markdown": "** foo bar**\n", "html": "** foo bar**
\n", "example": 378, "start_line": 6576, "end_line": 6580, "section": "Emphasis and strong emphasis" }, { "markdown": "a**\"foo\"**\n", "html": "a**"foo"**
\n", "example": 379, "start_line": 6587, "end_line": 6591, "section": "Emphasis and strong emphasis" }, { "markdown": "foo**bar**\n", "html": "foobar
\n", "example": 380, "start_line": 6596, "end_line": 6600, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo bar__\n", "html": "foo bar
\n", "example": 381, "start_line": 6605, "end_line": 6609, "section": "Emphasis and strong emphasis" }, { "markdown": "__ foo bar__\n", "html": "__ foo bar__
\n", "example": 382, "start_line": 6615, "end_line": 6619, "section": "Emphasis and strong emphasis" }, { "markdown": "__\nfoo bar__\n", "html": "__\nfoo bar__
\n", "example": 383, "start_line": 6623, "end_line": 6629, "section": "Emphasis and strong emphasis" }, { "markdown": "a__\"foo\"__\n", "html": "a__"foo"__
\n", "example": 384, "start_line": 6635, "end_line": 6639, "section": "Emphasis and strong emphasis" }, { "markdown": "foo__bar__\n", "html": "foo__bar__
\n", "example": 385, "start_line": 6644, "end_line": 6648, "section": "Emphasis and strong emphasis" }, { "markdown": "5__6__78\n", "html": "5__6__78
\n", "example": 386, "start_line": 6651, "end_line": 6655, "section": "Emphasis and strong emphasis" }, { "markdown": "пристаням__стремятся__\n", "html": "пристаням__стремятся__
\n", "example": 387, "start_line": 6658, "end_line": 6662, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo, __bar__, baz__\n", "html": "foo, bar, baz
\n", "example": 388, "start_line": 6665, "end_line": 6669, "section": "Emphasis and strong emphasis" }, { "markdown": "foo-__(bar)__\n", "html": "foo-(bar)
\n", "example": 389, "start_line": 6676, "end_line": 6680, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo bar **\n", "html": "**foo bar **
\n", "example": 390, "start_line": 6689, "end_line": 6693, "section": "Emphasis and strong emphasis" }, { "markdown": "**(**foo)\n", "html": "**(**foo)
\n", "example": 391, "start_line": 6702, "end_line": 6706, "section": "Emphasis and strong emphasis" }, { "markdown": "*(**foo**)*\n", "html": "(foo)
\n", "example": 392, "start_line": 6712, "end_line": 6716, "section": "Emphasis and strong emphasis" }, { "markdown": "**Gomphocarpus (*Gomphocarpus physocarpus*, syn.\n*Asclepias physocarpa*)**\n", "html": "Gomphocarpus (Gomphocarpus physocarpus, syn.\nAsclepias physocarpa)
\n", "example": 393, "start_line": 6719, "end_line": 6725, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo \"*bar*\" foo**\n", "html": "foo "bar" foo
\n", "example": 394, "start_line": 6728, "end_line": 6732, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo**bar\n", "html": "foobar
\n", "example": 395, "start_line": 6737, "end_line": 6741, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo bar __\n", "html": "__foo bar __
\n", "example": 396, "start_line": 6749, "end_line": 6753, "section": "Emphasis and strong emphasis" }, { "markdown": "__(__foo)\n", "html": "__(__foo)
\n", "example": 397, "start_line": 6759, "end_line": 6763, "section": "Emphasis and strong emphasis" }, { "markdown": "_(__foo__)_\n", "html": "(foo)
\n", "example": 398, "start_line": 6769, "end_line": 6773, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo__bar\n", "html": "__foo__bar
\n", "example": 399, "start_line": 6778, "end_line": 6782, "section": "Emphasis and strong emphasis" }, { "markdown": "__пристаням__стремятся\n", "html": "__пристаням__стремятся
\n", "example": 400, "start_line": 6785, "end_line": 6789, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo__bar__baz__\n", "html": "foo__bar__baz
\n", "example": 401, "start_line": 6792, "end_line": 6796, "section": "Emphasis and strong emphasis" }, { "markdown": "__(bar)__.\n", "html": "(bar).
\n", "example": 402, "start_line": 6803, "end_line": 6807, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo [bar](/url)*\n", "html": "foo bar
\n", "example": 403, "start_line": 6815, "end_line": 6819, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo\nbar*\n", "html": "foo\nbar
\n", "example": 404, "start_line": 6822, "end_line": 6828, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo __bar__ baz_\n", "html": "foo bar baz
\n", "example": 405, "start_line": 6834, "end_line": 6838, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo _bar_ baz_\n", "html": "foo bar baz
\n", "example": 406, "start_line": 6841, "end_line": 6845, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo_ bar_\n", "html": "foo bar
\n", "example": 407, "start_line": 6848, "end_line": 6852, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo *bar**\n", "html": "foo bar
\n", "example": 408, "start_line": 6855, "end_line": 6859, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo **bar** baz*\n", "html": "foo bar baz
\n", "example": 409, "start_line": 6862, "end_line": 6866, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo**bar**baz*\n", "html": "foobarbaz
\n", "example": 410, "start_line": 6868, "end_line": 6872, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo**bar*\n", "html": "foo**bar
\n", "example": 411, "start_line": 6892, "end_line": 6896, "section": "Emphasis and strong emphasis" }, { "markdown": "***foo** bar*\n", "html": "foo bar
\n", "example": 412, "start_line": 6905, "end_line": 6909, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo **bar***\n", "html": "foo bar
\n", "example": 413, "start_line": 6912, "end_line": 6916, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo**bar***\n", "html": "foobar
\n", "example": 414, "start_line": 6919, "end_line": 6923, "section": "Emphasis and strong emphasis" }, { "markdown": "foo***bar***baz\n", "html": "foobarbaz
\n", "example": 415, "start_line": 6930, "end_line": 6934, "section": "Emphasis and strong emphasis" }, { "markdown": "foo******bar*********baz\n", "html": "foobar***baz
\n", "example": 416, "start_line": 6936, "end_line": 6940, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo **bar *baz* bim** bop*\n", "html": "foo bar baz bim bop
\n", "example": 417, "start_line": 6945, "end_line": 6949, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo [*bar*](/url)*\n", "html": "foo bar
\n", "example": 418, "start_line": 6952, "end_line": 6956, "section": "Emphasis and strong emphasis" }, { "markdown": "** is not an empty emphasis\n", "html": "** is not an empty emphasis
\n", "example": 419, "start_line": 6961, "end_line": 6965, "section": "Emphasis and strong emphasis" }, { "markdown": "**** is not an empty strong emphasis\n", "html": "**** is not an empty strong emphasis
\n", "example": 420, "start_line": 6968, "end_line": 6972, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo [bar](/url)**\n", "html": "foo bar
\n", "example": 421, "start_line": 6981, "end_line": 6985, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo\nbar**\n", "html": "foo\nbar
\n", "example": 422, "start_line": 6988, "end_line": 6994, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo _bar_ baz__\n", "html": "foo bar baz
\n", "example": 423, "start_line": 7000, "end_line": 7004, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo __bar__ baz__\n", "html": "foo bar baz
\n", "example": 424, "start_line": 7007, "end_line": 7011, "section": "Emphasis and strong emphasis" }, { "markdown": "____foo__ bar__\n", "html": "foo bar
\n", "example": 425, "start_line": 7014, "end_line": 7018, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo **bar****\n", "html": "foo bar
\n", "example": 426, "start_line": 7021, "end_line": 7025, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo *bar* baz**\n", "html": "foo bar baz
\n", "example": 427, "start_line": 7028, "end_line": 7032, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo*bar*baz**\n", "html": "foobarbaz
\n", "example": 428, "start_line": 7035, "end_line": 7039, "section": "Emphasis and strong emphasis" }, { "markdown": "***foo* bar**\n", "html": "foo bar
\n", "example": 429, "start_line": 7042, "end_line": 7046, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo *bar***\n", "html": "foo bar
\n", "example": 430, "start_line": 7049, "end_line": 7053, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo *bar **baz**\nbim* bop**\n", "html": "foo bar baz\nbim bop
\n", "example": 431, "start_line": 7058, "end_line": 7064, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo [*bar*](/url)**\n", "html": "foo bar
\n", "example": 432, "start_line": 7067, "end_line": 7071, "section": "Emphasis and strong emphasis" }, { "markdown": "__ is not an empty emphasis\n", "html": "__ is not an empty emphasis
\n", "example": 433, "start_line": 7076, "end_line": 7080, "section": "Emphasis and strong emphasis" }, { "markdown": "____ is not an empty strong emphasis\n", "html": "____ is not an empty strong emphasis
\n", "example": 434, "start_line": 7083, "end_line": 7087, "section": "Emphasis and strong emphasis" }, { "markdown": "foo ***\n", "html": "foo ***
\n", "example": 435, "start_line": 7093, "end_line": 7097, "section": "Emphasis and strong emphasis" }, { "markdown": "foo *\\**\n", "html": "foo *
\n", "example": 436, "start_line": 7100, "end_line": 7104, "section": "Emphasis and strong emphasis" }, { "markdown": "foo *_*\n", "html": "foo _
\n", "example": 437, "start_line": 7107, "end_line": 7111, "section": "Emphasis and strong emphasis" }, { "markdown": "foo *****\n", "html": "foo *****
\n", "example": 438, "start_line": 7114, "end_line": 7118, "section": "Emphasis and strong emphasis" }, { "markdown": "foo **\\***\n", "html": "foo *
\n", "example": 439, "start_line": 7121, "end_line": 7125, "section": "Emphasis and strong emphasis" }, { "markdown": "foo **_**\n", "html": "foo _
\n", "example": 440, "start_line": 7128, "end_line": 7132, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo*\n", "html": "*foo
\n", "example": 441, "start_line": 7139, "end_line": 7143, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo**\n", "html": "foo*
\n", "example": 442, "start_line": 7146, "end_line": 7150, "section": "Emphasis and strong emphasis" }, { "markdown": "***foo**\n", "html": "*foo
\n", "example": 443, "start_line": 7153, "end_line": 7157, "section": "Emphasis and strong emphasis" }, { "markdown": "****foo*\n", "html": "***foo
\n", "example": 444, "start_line": 7160, "end_line": 7164, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo***\n", "html": "foo*
\n", "example": 445, "start_line": 7167, "end_line": 7171, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo****\n", "html": "foo***
\n", "example": 446, "start_line": 7174, "end_line": 7178, "section": "Emphasis and strong emphasis" }, { "markdown": "foo ___\n", "html": "foo ___
\n", "example": 447, "start_line": 7184, "end_line": 7188, "section": "Emphasis and strong emphasis" }, { "markdown": "foo _\\__\n", "html": "foo _
\n", "example": 448, "start_line": 7191, "end_line": 7195, "section": "Emphasis and strong emphasis" }, { "markdown": "foo _*_\n", "html": "foo *
\n", "example": 449, "start_line": 7198, "end_line": 7202, "section": "Emphasis and strong emphasis" }, { "markdown": "foo _____\n", "html": "foo _____
\n", "example": 450, "start_line": 7205, "end_line": 7209, "section": "Emphasis and strong emphasis" }, { "markdown": "foo __\\___\n", "html": "foo _
\n", "example": 451, "start_line": 7212, "end_line": 7216, "section": "Emphasis and strong emphasis" }, { "markdown": "foo __*__\n", "html": "foo *
\n", "example": 452, "start_line": 7219, "end_line": 7223, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo_\n", "html": "_foo
\n", "example": 453, "start_line": 7226, "end_line": 7230, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo__\n", "html": "foo_
\n", "example": 454, "start_line": 7237, "end_line": 7241, "section": "Emphasis and strong emphasis" }, { "markdown": "___foo__\n", "html": "_foo
\n", "example": 455, "start_line": 7244, "end_line": 7248, "section": "Emphasis and strong emphasis" }, { "markdown": "____foo_\n", "html": "___foo
\n", "example": 456, "start_line": 7251, "end_line": 7255, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo___\n", "html": "foo_
\n", "example": 457, "start_line": 7258, "end_line": 7262, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo____\n", "html": "foo___
\n", "example": 458, "start_line": 7265, "end_line": 7269, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo**\n", "html": "foo
\n", "example": 459, "start_line": 7275, "end_line": 7279, "section": "Emphasis and strong emphasis" }, { "markdown": "*_foo_*\n", "html": "foo
\n", "example": 460, "start_line": 7282, "end_line": 7286, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo__\n", "html": "foo
\n", "example": 461, "start_line": 7289, "end_line": 7293, "section": "Emphasis and strong emphasis" }, { "markdown": "_*foo*_\n", "html": "foo
\n", "example": 462, "start_line": 7296, "end_line": 7300, "section": "Emphasis and strong emphasis" }, { "markdown": "****foo****\n", "html": "foo
\n", "example": 463, "start_line": 7306, "end_line": 7310, "section": "Emphasis and strong emphasis" }, { "markdown": "____foo____\n", "html": "foo
\n", "example": 464, "start_line": 7313, "end_line": 7317, "section": "Emphasis and strong emphasis" }, { "markdown": "******foo******\n", "html": "foo
\n", "example": 465, "start_line": 7324, "end_line": 7328, "section": "Emphasis and strong emphasis" }, { "markdown": "***foo***\n", "html": "foo
\n", "example": 466, "start_line": 7333, "end_line": 7337, "section": "Emphasis and strong emphasis" }, { "markdown": "_____foo_____\n", "html": "foo
\n", "example": 467, "start_line": 7340, "end_line": 7344, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo _bar* baz_\n", "html": "foo _bar baz_
\n", "example": 468, "start_line": 7349, "end_line": 7353, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo __bar *baz bim__ bam*\n", "html": "foo bar *baz bim bam
\n", "example": 469, "start_line": 7356, "end_line": 7360, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo **bar baz**\n", "html": "**foo bar baz
\n", "example": 470, "start_line": 7365, "end_line": 7369, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo *bar baz*\n", "html": "*foo bar baz
\n", "example": 471, "start_line": 7372, "end_line": 7376, "section": "Emphasis and strong emphasis" }, { "markdown": "*[bar*](/url)\n", "html": "*bar*
\n", "example": 472, "start_line": 7381, "end_line": 7385, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo [bar_](/url)\n", "html": "_foo bar_
\n", "example": 473, "start_line": 7388, "end_line": 7392, "section": "Emphasis and strong emphasis" }, { "markdown": "**
a *
a _
[link](/my uri)
\n", "example": 487, "start_line": 7571, "end_line": 7575, "section": "Links" }, { "markdown": "[link]([link](foo\nbar)
\n", "example": 489, "start_line": 7586, "end_line": 7592, "section": "Links" }, { "markdown": "[link]([link](
[link](<foo>)
\n", "example": 492, "start_line": 7613, "end_line": 7617, "section": "Links" }, { "markdown": "[a](\n[a](c)\n", "html": "[a](<b)c\n[a](<b)c>\n[a](c)
\n", "example": 493, "start_line": 7622, "end_line": 7630, "section": "Links" }, { "markdown": "[link](\\(foo\\))\n", "html": "\n", "example": 494, "start_line": 7634, "end_line": 7638, "section": "Links" }, { "markdown": "[link](foo(and(bar)))\n", "html": "\n", "example": 495, "start_line": 7643, "end_line": 7647, "section": "Links" }, { "markdown": "[link](foo(and(bar))\n", "html": "[link](foo(and(bar))
\n", "example": 496, "start_line": 7652, "end_line": 7656, "section": "Links" }, { "markdown": "[link](foo\\(and\\(bar\\))\n", "html": "\n", "example": 497, "start_line": 7659, "end_line": 7663, "section": "Links" }, { "markdown": "[link]([link](/url "title "and" title")
\n", "example": 507, "start_line": 7771, "end_line": 7775, "section": "Links" }, { "markdown": "[link](/url 'title \"and\" title')\n", "html": "\n", "example": 508, "start_line": 7780, "end_line": 7784, "section": "Links" }, { "markdown": "[link]( /uri\n \"title\" )\n", "html": "\n", "example": 509, "start_line": 7805, "end_line": 7810, "section": "Links" }, { "markdown": "[link] (/uri)\n", "html": "[link] (/uri)
\n", "example": 510, "start_line": 7816, "end_line": 7820, "section": "Links" }, { "markdown": "[link [foo [bar]]](/uri)\n", "html": "\n", "example": 511, "start_line": 7826, "end_line": 7830, "section": "Links" }, { "markdown": "[link] bar](/uri)\n", "html": "[link] bar](/uri)
\n", "example": 512, "start_line": 7833, "end_line": 7837, "section": "Links" }, { "markdown": "[link [bar](/uri)\n", "html": "[link bar
\n", "example": 513, "start_line": 7840, "end_line": 7844, "section": "Links" }, { "markdown": "[link \\[bar](/uri)\n", "html": "\n", "example": 514, "start_line": 7847, "end_line": 7851, "section": "Links" }, { "markdown": "[link *foo **bar** `#`*](/uri)\n", "html": "\n", "example": 515, "start_line": 7856, "end_line": 7860, "section": "Links" }, { "markdown": "[](/uri)\n", "html": "\n", "example": 516, "start_line": 7863, "end_line": 7867, "section": "Links" }, { "markdown": "[foo [bar](/uri)](/uri)\n", "html": "[foo bar](/uri)
\n", "example": 517, "start_line": 7872, "end_line": 7876, "section": "Links" }, { "markdown": "[foo *[bar [baz](/uri)](/uri)*](/uri)\n", "html": "[foo [bar baz](/uri)](/uri)
\n", "example": 518, "start_line": 7879, "end_line": 7883, "section": "Links" }, { "markdown": "](uri2)](uri3)\n", "html": "*foo*
\n", "example": 520, "start_line": 7896, "end_line": 7900, "section": "Links" }, { "markdown": "[foo *bar](baz*)\n", "html": "\n", "example": 521, "start_line": 7903, "end_line": 7907, "section": "Links" }, { "markdown": "*foo [bar* baz]\n", "html": "foo [bar baz]
\n", "example": 522, "start_line": 7913, "end_line": 7917, "section": "Links" }, { "markdown": "[foo[foo
[foo](/uri)
[foohttp://example.com/?search=](uri)
\n", "example": 525, "start_line": 7937, "end_line": 7941, "section": "Links" }, { "markdown": "[foo][bar]\n\n[bar]: /url \"title\"\n", "html": "\n", "example": 526, "start_line": 7975, "end_line": 7981, "section": "Links" }, { "markdown": "[link [foo [bar]]][ref]\n\n[ref]: /uri\n", "html": "\n", "example": 527, "start_line": 7990, "end_line": 7996, "section": "Links" }, { "markdown": "[link \\[bar][ref]\n\n[ref]: /uri\n", "html": "\n", "example": 528, "start_line": 7999, "end_line": 8005, "section": "Links" }, { "markdown": "[link *foo **bar** `#`*][ref]\n\n[ref]: /uri\n", "html": "\n", "example": 529, "start_line": 8010, "end_line": 8016, "section": "Links" }, { "markdown": "[][ref]\n\n[ref]: /uri\n", "html": "\n", "example": 530, "start_line": 8019, "end_line": 8025, "section": "Links" }, { "markdown": "[foo [bar](/uri)][ref]\n\n[ref]: /uri\n", "html": "\n", "example": 531, "start_line": 8030, "end_line": 8036, "section": "Links" }, { "markdown": "[foo *bar [baz][ref]*][ref]\n\n[ref]: /uri\n", "html": "\n", "example": 532, "start_line": 8039, "end_line": 8045, "section": "Links" }, { "markdown": "*[foo*][ref]\n\n[ref]: /uri\n", "html": "*foo*
\n", "example": 533, "start_line": 8054, "end_line": 8060, "section": "Links" }, { "markdown": "[foo *bar][ref]*\n\n[ref]: /uri\n", "html": "\n", "example": 534, "start_line": 8063, "end_line": 8069, "section": "Links" }, { "markdown": "[foo[foo
[foo][ref]
[foohttp://example.com/?search=][ref]
\n", "example": 537, "start_line": 8093, "end_line": 8099, "section": "Links" }, { "markdown": "[foo][BaR]\n\n[bar]: /url \"title\"\n", "html": "\n", "example": 538, "start_line": 8104, "end_line": 8110, "section": "Links" }, { "markdown": "[ẞ]\n\n[SS]: /url\n", "html": "\n", "example": 539, "start_line": 8115, "end_line": 8121, "section": "Links" }, { "markdown": "[Foo\n bar]: /url\n\n[Baz][Foo bar]\n", "html": "\n", "example": 540, "start_line": 8127, "end_line": 8134, "section": "Links" }, { "markdown": "[foo] [bar]\n\n[bar]: /url \"title\"\n", "html": "[foo] bar
\n", "example": 541, "start_line": 8140, "end_line": 8146, "section": "Links" }, { "markdown": "[foo]\n[bar]\n\n[bar]: /url \"title\"\n", "html": "[foo]\nbar
\n", "example": 542, "start_line": 8149, "end_line": 8157, "section": "Links" }, { "markdown": "[foo]: /url1\n\n[foo]: /url2\n\n[bar][foo]\n", "html": "\n", "example": 543, "start_line": 8190, "end_line": 8198, "section": "Links" }, { "markdown": "[bar][foo\\!]\n\n[foo!]: /url\n", "html": "[bar][foo!]
\n", "example": 544, "start_line": 8205, "end_line": 8211, "section": "Links" }, { "markdown": "[foo][ref[]\n\n[ref[]: /uri\n", "html": "[foo][ref[]
\n[ref[]: /uri
\n", "example": 545, "start_line": 8217, "end_line": 8224, "section": "Links" }, { "markdown": "[foo][ref[bar]]\n\n[ref[bar]]: /uri\n", "html": "[foo][ref[bar]]
\n[ref[bar]]: /uri
\n", "example": 546, "start_line": 8227, "end_line": 8234, "section": "Links" }, { "markdown": "[[[foo]]]\n\n[[[foo]]]: /url\n", "html": "[[[foo]]]
\n[[[foo]]]: /url
\n", "example": 547, "start_line": 8237, "end_line": 8244, "section": "Links" }, { "markdown": "[foo][ref\\[]\n\n[ref\\[]: /uri\n", "html": "\n", "example": 548, "start_line": 8247, "end_line": 8253, "section": "Links" }, { "markdown": "[bar\\\\]: /uri\n\n[bar\\\\]\n", "html": "\n", "example": 549, "start_line": 8258, "end_line": 8264, "section": "Links" }, { "markdown": "[]\n\n[]: /uri\n", "html": "[]
\n[]: /uri
\n", "example": 550, "start_line": 8270, "end_line": 8277, "section": "Links" }, { "markdown": "[\n ]\n\n[\n ]: /uri\n", "html": "[\n]
\n[\n]: /uri
\n", "example": 551, "start_line": 8280, "end_line": 8291, "section": "Links" }, { "markdown": "[foo][]\n\n[foo]: /url \"title\"\n", "html": "\n", "example": 552, "start_line": 8303, "end_line": 8309, "section": "Links" }, { "markdown": "[*foo* bar][]\n\n[*foo* bar]: /url \"title\"\n", "html": "\n", "example": 553, "start_line": 8312, "end_line": 8318, "section": "Links" }, { "markdown": "[Foo][]\n\n[foo]: /url \"title\"\n", "html": "\n", "example": 554, "start_line": 8323, "end_line": 8329, "section": "Links" }, { "markdown": "[foo] \n[]\n\n[foo]: /url \"title\"\n", "html": "foo\n[]
\n", "example": 555, "start_line": 8336, "end_line": 8344, "section": "Links" }, { "markdown": "[foo]\n\n[foo]: /url \"title\"\n", "html": "\n", "example": 556, "start_line": 8356, "end_line": 8362, "section": "Links" }, { "markdown": "[*foo* bar]\n\n[*foo* bar]: /url \"title\"\n", "html": "\n", "example": 557, "start_line": 8365, "end_line": 8371, "section": "Links" }, { "markdown": "[[*foo* bar]]\n\n[*foo* bar]: /url \"title\"\n", "html": "[foo bar]
\n", "example": 558, "start_line": 8374, "end_line": 8380, "section": "Links" }, { "markdown": "[[bar [foo]\n\n[foo]: /url\n", "html": "[[bar foo
\n", "example": 559, "start_line": 8383, "end_line": 8389, "section": "Links" }, { "markdown": "[Foo]\n\n[foo]: /url \"title\"\n", "html": "\n", "example": 560, "start_line": 8394, "end_line": 8400, "section": "Links" }, { "markdown": "[foo] bar\n\n[foo]: /url\n", "html": "foo bar
\n", "example": 561, "start_line": 8405, "end_line": 8411, "section": "Links" }, { "markdown": "\\[foo]\n\n[foo]: /url \"title\"\n", "html": "[foo]
\n", "example": 562, "start_line": 8417, "end_line": 8423, "section": "Links" }, { "markdown": "[foo*]: /url\n\n*[foo*]\n", "html": "*foo*
\n", "example": 563, "start_line": 8429, "end_line": 8435, "section": "Links" }, { "markdown": "[foo][bar]\n\n[foo]: /url1\n[bar]: /url2\n", "html": "\n", "example": 564, "start_line": 8441, "end_line": 8448, "section": "Links" }, { "markdown": "[foo][]\n\n[foo]: /url1\n", "html": "\n", "example": 565, "start_line": 8450, "end_line": 8456, "section": "Links" }, { "markdown": "[foo]()\n\n[foo]: /url1\n", "html": "\n", "example": 566, "start_line": 8460, "end_line": 8466, "section": "Links" }, { "markdown": "[foo](not a link)\n\n[foo]: /url1\n", "html": "foo(not a link)
\n", "example": 567, "start_line": 8468, "end_line": 8474, "section": "Links" }, { "markdown": "[foo][bar][baz]\n\n[baz]: /url\n", "html": "[foo]bar
\n", "example": 568, "start_line": 8479, "end_line": 8485, "section": "Links" }, { "markdown": "[foo][bar][baz]\n\n[baz]: /url1\n[bar]: /url2\n", "html": "\n", "example": 569, "start_line": 8491, "end_line": 8498, "section": "Links" }, { "markdown": "[foo][bar][baz]\n\n[baz]: /url1\n[foo]: /url2\n", "html": "[foo]bar
\n", "example": 570, "start_line": 8504, "end_line": 8511, "section": "Links" }, { "markdown": "\n", "html": "My
\n[]
![[foo]]
\n[[foo]]: /url "title"
\n", "example": 589, "start_line": 8697, "end_line": 8704, "section": "Images" }, { "markdown": "![Foo]\n\n[foo]: /url \"title\"\n", "html": "![foo]
\n", "example": 591, "start_line": 8721, "end_line": 8727, "section": "Images" }, { "markdown": "\\![foo]\n\n[foo]: /url \"title\"\n", "html": "!foo
\n", "example": 592, "start_line": 8733, "end_line": 8739, "section": "Images" }, { "markdown": "http://foo.bar.baz/test?q=hello&id=22&boolean
\n", "example": 594, "start_line": 8773, "end_line": 8777, "section": "Autolinks" }, { "markdown": "<http://foo.bar/baz bim>
\n", "example": 601, "start_line": 8831, "end_line": 8835, "section": "Autolinks" }, { "markdown": "<foo+@bar.example.com>
\n", "example": 605, "start_line": 8878, "end_line": 8882, "section": "Autolinks" }, { "markdown": "<>\n", "html": "<>
\n", "example": 606, "start_line": 8887, "end_line": 8891, "section": "Autolinks" }, { "markdown": "< http://foo.bar >\n", "html": "< http://foo.bar >
\n", "example": 607, "start_line": 8894, "end_line": 8898, "section": "Autolinks" }, { "markdown": "<m:abc>
\n", "example": 608, "start_line": 8901, "end_line": 8905, "section": "Autolinks" }, { "markdown": "<foo.bar.baz>
\n", "example": 609, "start_line": 8908, "end_line": 8912, "section": "Autolinks" }, { "markdown": "http://example.com\n", "html": "http://example.com
\n", "example": 610, "start_line": 8915, "end_line": 8919, "section": "Autolinks" }, { "markdown": "foo@bar.example.com\n", "html": "foo@bar.example.com
\n", "example": 611, "start_line": 8922, "end_line": 8926, "section": "Autolinks" }, { "markdown": "Foo
<33> <__>
\n", "example": 617, "start_line": 9051, "end_line": 9055, "section": "Raw HTML" }, { "markdown": "\n", "html": "<a h*#ref="hi">
\n", "example": 618, "start_line": 9060, "end_line": 9064, "section": "Raw HTML" }, { "markdown": " \n", "html": "<a href="hi'> <a href=hi'>
\n", "example": 619, "start_line": 9069, "end_line": 9073, "section": "Raw HTML" }, { "markdown": "< a><\nfoo>< a><\nfoo><bar/ >\n<foo bar=baz\nbim!bop />
\n", "example": 620, "start_line": 9078, "end_line": 9088, "section": "Raw HTML" }, { "markdown": "\n", "html": "<a href='bar'title=title>
\n", "example": 621, "start_line": 9093, "end_line": 9097, "section": "Raw HTML" }, { "markdown": "</a href="foo">
\n", "example": 623, "start_line": 9111, "end_line": 9115, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "foo
\n", "example": 624, "start_line": 9120, "end_line": 9126, "section": "Raw HTML" }, { "markdown": "foo foo -->\n\nfoo foo -->\n", "html": "foo foo -->
\nfoo foo -->
\n", "example": 625, "start_line": 9128, "end_line": 9135, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "foo
\n", "example": 626, "start_line": 9140, "end_line": 9144, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "foo
\n", "example": 627, "start_line": 9149, "end_line": 9153, "section": "Raw HTML" }, { "markdown": "foo &<]]>\n", "html": "foo &<]]>
\n", "example": 628, "start_line": 9158, "end_line": 9162, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "\n", "example": 629, "start_line": 9168, "end_line": 9172, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "\n", "example": 630, "start_line": 9177, "end_line": 9181, "section": "Raw HTML" }, { "markdown": "\n", "html": "<a href=""">
\n", "example": 631, "start_line": 9184, "end_line": 9188, "section": "Raw HTML" }, { "markdown": "foo \nbaz\n", "html": "foo
\nbaz
foo
\nbaz
foo
\nbaz
foo
\nbar
foo
\nbar
foo
\nbar
foo
\nbar
code span
code\\ span
foo\\
\n", "example": 643, "start_line": 9313, "end_line": 9317, "section": "Hard line breaks" }, { "markdown": "foo \n", "html": "foo
\n", "example": 644, "start_line": 9320, "end_line": 9324, "section": "Hard line breaks" }, { "markdown": "### foo\\\n", "html": "foo\nbaz
\n", "example": 647, "start_line": 9349, "end_line": 9355, "section": "Soft line breaks" }, { "markdown": "foo \n baz\n", "html": "foo\nbaz
\n", "example": 648, "start_line": 9361, "end_line": 9367, "section": "Soft line breaks" }, { "markdown": "hello $.;'there\n", "html": "hello $.;'there
\n", "example": 649, "start_line": 9381, "end_line": 9385, "section": "Textual content" }, { "markdown": "Foo χρῆν\n", "html": "Foo χρῆν
\n", "example": 650, "start_line": 9388, "end_line": 9392, "section": "Textual content" }, { "markdown": "Multiple spaces\n", "html": "Multiple spaces
\n", "example": 651, "start_line": 9397, "end_line": 9401, "section": "Textual content" } ] elvish-0.20.1/pkg/md/stack.go 0000664 0000000 0000000 00000000342 14570151573 0015710 0 ustar 00root root 0000000 0000000 package md type stack[T any] []T func (s *stack[T]) push(v T) { *s = append(*s, v) } func (s stack[T]) peek() T { return s[len(s)-1] } func (s *stack[T]) pop() T { last := s.peek() *s = (*s)[:len(*s)-1] return last } elvish-0.20.1/pkg/md/testdata/ 0000775 0000000 0000000 00000000000 14570151573 0016066 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/ 0000775 0000000 0000000 00000000000 14570151573 0017064 5 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender/ 0000775 0000000 0000000 00000000000 14570151573 0024215 5 ustar 00root root 0000000 0000000 09165d96e6eede6b6057e300935fb1aa5c98243ed4f94b75a3ae31fb129e696d 0000664 0000000 0000000 00000000044 14570151573 0034762 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("[](<>(0))") 0d768707e28d09f6ef49b5e8c7e8f44fbea157f6296a05abb302ee5b77e3a168 0000664 0000000 0000000 00000000040 14570151573 0035051 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("<&@0>") 17c530b620d9fbcf8870fe975ed11dd7062eb582d7ca3fc5ddbc293d5344e2f9 0000664 0000000 0000000 00000000115 14570151573 0035174 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("999999990)\n0)\n0)\n0)\n0)\n0)\n0)\n0)\n0)\n0)\n0)") 2b69704609fc2e3745fba9a435b29b3858c597efe349cf8cc39c193e6f02173c 0000664 0000000 0000000 00000000041 14570151573 0034635 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("\\ \n0") 2ced69587e727c3c37b87201e0e44e450090e6a195425a8fe0dc66bbc5163fd6 0000664 0000000 0000000 00000000042 14570151573 0034605 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("[]( <0)") 2ec9c489ff1c9ed8d8a24c2eb9e4033303149dcd9875ea82bf4764d0df144af5 0000664 0000000 0000000 00000000043 14570151573 0035125 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("* ```\n0") 334ff8e8eaece3f84601f65db41d9cffa5634be1d23c4f97ea44edf4669f712f 0000664 0000000 0000000 00000000043 14570151573 0035354 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string(" ***") 3463752fb4670a5a72d0033d9f2caee37023353a019825bb48223e7a01d9b760 0000664 0000000 0000000 00000000043 14570151573 0034246 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("_0 _") 4310d034c2ad018a0649d15c7d6748bfad6779fb067b28b09debb146eb77bd31 0000664 0000000 0000000 00000000043 14570151573 0034725 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("*\n+ ***") 43d96cdf434f4f13cd32c391724d7c1cbc55d63d92195ea9da67c397abb722fa 0000664 0000000 0000000 00000000041 14570151573 0035104 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string(">\\---") 45f9492476d79ae796da2ffeb3476370586553a5071b32bd1cc7a07fbd80356f 0000664 0000000 0000000 00000000041 14570151573 0034544 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("* --") 46d002372d39c68359423d57dbe63c3c83003ecbd5e601286a5f80bc691624ef 0000664 0000000 0000000 00000000042 14570151573 0034435 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("_00*0*_") 4951856280eb053ef3a943dcf6fb1e27cd595e4ab8aa00934517a702e71ca940 0000664 0000000 0000000 00000000042 14570151573 0034571 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("- - - *") 49ef19e3514a8f95ff404e89d70d308b76d947b505a9593e2e72712048f2ae42 0000664 0000000 0000000 00000000044 14570151573 0034420 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("*\n ") 51e230c835df97b3aec428fe7a0be1704811421c5c28a78c9c3b74983157b1e4 0000664 0000000 0000000 00000000044 14570151573 0034517 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("*`0`*") 594ea6c06082c3fc565a1304ce5a809d2a37dac682163c23c73be7748bc5b464 0000664 0000000 0000000 00000000052 14570151573 0034570 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("*0\n ***\n0*") 5e0c9718d6e600b573a921052b15c44fb57a68a9d70af7a7c8899b33adefcc40 0000664 0000000 0000000 00000000052 14570151573 0034745 0 ustar 00root root 0000000 0000000 elvish-0.20.1/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("0\na
a `), HTML: dedent(`a
`), }, { Section: "HTML blocks", Name: "Closed by insufficient list item indentation", Markdown: dedent(` -a `), HTML: dedent(`
a
`), }, { Section: "Blockquotes", Name: "Increasing level", Markdown: dedent(` > a >> b `), HTML: dedent(``), }, { Section: "Blockquotes", Name: "Reducing level", Markdown: dedent(` >> a > > b `), HTML: dedent(`a
b
`), }, { Section: "List items", Name: "Two leading empty lines with spaces", Markdown: dedent(` - a `), HTML: dedent(`a
b
a
`), }, { Section: "List", Name: "Two-level bullet list with no content interrupting paragraph", Markdown: dedent(` a - - `), HTML: dedent(`a
a
a*$*
` + "\n", }, { Section: "Links", Name: "Backslash and entity in destination", Markdown: `[a](\>)`, HTML: `` + "\n", }, { Section: "Links", Name: "Backslash and entity in title", Markdown: `[a](b (\>))`, HTML: `` + "\n", }, { Section: "Links", Name: "Unmatched ( in destination, with title", Markdown: `[a](http://( "b")`, HTML: "[a](http://( "b")
\n", }, { Section: "Links", Name: "Unescaped ( in title started with (", Markdown: `[a](b (()))`, HTML: "[a](b (()))
\n", }, { Section: "Links", Name: "Literal & in destination", Markdown: `[a](http://b?c&d)`, HTML: `` + "\n", }, { Section: "Image", Name: "Omit hard line break tag in alt", Markdown: dedent(`  `), HTML: dedent(`a<
\n", }, { Section: "Raw HTML", Name: "unclosed bar `), ttyRender: ui.T(dedent(` foo bar `)), }, { name: "blockquote", markdown: dedent(` Quote: > foo >> lorem > > bar `), ttyRender: ui.T(dedent(` Quote: │ foo │ │ │ lorem │ │ bar `)), }, { name: "bullet list", markdown: dedent(` List: - one more - two more `), ttyRender: ui.T(dedent(` List: • one more • two more `)), }, { name: "ordered list", markdown: dedent(` List: 1. one more 1. two more `), ttyRender: ui.T(dedent(` List: 1. one more 2. two more `)), }, { name: "nested blocks", markdown: dedent(` > foo > - item > 1. one > 1. another > - another item `), ttyRender: ui.T(dedent(` │ foo │ │ • item │ │ 1. one │ │ 2. another │ │ • another item `)), }, // Highlight code block { name: "highlight", markdown: dedent(` Some code: ~~~foo bar code content ~~~ `), highlight: func(info, code string) ui.Text { return ui.T(fmt.Sprintf("(%s) %q\n", info, code)) }, ttyRender: ui.T(dedent(` Some code: (foo bar) "code content\n" `)), }, { name: "highlight missing trailing newline", markdown: dedent(` Some code: ~~~foo bar code content ~~~ `), highlight: func(info, code string) ui.Text { return ui.T(fmt.Sprintf("(%s) %q", info, code)) }, ttyRender: ui.T(dedent(` Some code: (foo bar) "code content\n" `)), }, // Inline { name: "text", markdown: "foo bar", ttyRender: ui.T("foo bar\n"), }, { name: "inline kbd tag", markdown: "Press Enter.", ttyRender: markLines( "Press Enter.", stylesheet, " ^^^^^ "), }, { name: "code span", markdown: "Use `put`.", ttyRender: markLines( "Use put.", stylesheet, " ___ "), }, { name: "emphasis", markdown: "Try *this*.", ttyRender: markLines( "Try this.", stylesheet, " //// "), }, { name: "strong emphasis", markdown: "Try **that**.", ttyRender: markLines( "Try that.", stylesheet, " #### "), }, { name: "link with absolute destination", markdown: "Visit [example](https://example.com).", ttyRender: markLines( "Visit example (https://example.com).", stylesheet, " _______ "), }, { name: "link with relative destination", markdown: "See [section X](#x) and [page Y](y.html).", ttyRender: markLines( "See section X and page Y.", stylesheet, " _________ ______ "), }, { name: "image", markdown: "", ttyRender: ui.T("Image: Example logo (https://example.com/logo.png)\n"), }, { name: "autolink", markdown: "Visit