elvish-0.21.0/ 0000775 0000000 0000000 00000000000 14657203754 0013101 5 ustar 00root root 0000000 0000000 elvish-0.21.0/.cirrus.yml 0000664 0000000 0000000 00000011110 14657203754 0015203 0 ustar 00root root 0000000 0000000 test_arm_task:
env:
ELVISH_TEST_TIME_SCALE: "20"
TEST_FLAG: -race
name: Test on Linux ARM64
arm_container:
# The Alpine image has segmentation faults when running test -race, so
# use Debian instead.
image: golang:1.22-bookworm
go_version_script: go version
test_script: go test $TEST_FLAG ./...
test_bsd_task:
env:
ELVISH_TEST_TIME_SCALE: "20"
TEST_FLAG: -race
GO_VERSION: "1.22.0"
PATH: /usr/local/go/bin:$PATH
matrix:
- name: Test on FreeBSD
freebsd_instance:
# Find latest version on https://www.freebsd.org/releases/
image_family: freebsd-14-0
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
- name: Test on NetBSD
compute_engine_instance:
image_project: pg-ci-images
# Find latest version in the "VERSION:" variable for the NetBSD image in
# https://github.com/anarazel/pg-vm-images/blob/main/.cirrus.yml
image: family/pg-ci-netbsd-vanilla-9-3
platform: netbsd
- name: Test on OpenBSD
compute_engine_instance:
image_project: pg-ci-images
# Find latest version in the "VERSION:" variable for the OpenBSD image in
# https://github.com/anarazel/pg-vm-images/blob/main/.cirrus.yml
image: family/pg-ci-openbsd-vanilla-7-3
platform: openbsd
go_toolchain_cache:
fingerprint_key: $CIRRUS_OS-$GO_VERSION
folder: /usr/local/go
populate_script: |
curl -L -o go.tar.gz https://go.dev/dl/go$GO_VERSION.$CIRRUS_OS-amd64.tar.gz
mkdir -p /usr/local
tar -C /usr/local -xzf go.tar.gz
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.22.0-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: |
go run ./cmd/elvish ./tools/buildall.elv -name elvish-HEAD -variant official ./cmd/elvish _bin/
binaries_artifacts:
path: _bin/**
binary_checksums_artifacts:
path: _bin/*/*.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
if website_ts=$(curl -sSf https://$HOST.elv.sh/commit-ts.txt); then
if test "$website_ts" -ge "$ts"; then
echo "website ($website_ts) >= CI ($ts)"
exit 0
else
echo "website ($website_ts) < CI ($ts)"
fi
else
echo "website has no commit-ts.txt yet"
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 */elvish-HEAD.exe.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.21.0/.codecov.yml 0000664 0000000 0000000 00000001307 14657203754 0015325 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.21.0/.codespellrc 0000664 0000000 0000000 00000000241 14657203754 0015376 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.21.0/.dockerignore 0000664 0000000 0000000 00000000004 14657203754 0015547 0 ustar 00root root 0000000 0000000 /_*
elvish-0.21.0/.gitattributes 0000664 0000000 0000000 00000000026 14657203754 0015772 0 ustar 00root root 0000000 0000000 *.go filter=goimports
elvish-0.21.0/.github/ 0000775 0000000 0000000 00000000000 14657203754 0014441 5 ustar 00root root 0000000 0000000 elvish-0.21.0/.github/FUNDING.yml 0000664 0000000 0000000 00000000033 14657203754 0016252 0 ustar 00root root 0000000 0000000 github: xiaq
patreon: xiaq
elvish-0.21.0/.github/ISSUE_TEMPLATE/ 0000775 0000000 0000000 00000000000 14657203754 0016624 5 ustar 00root root 0000000 0000000 elvish-0.21.0/.github/ISSUE_TEMPLATE/bug_report.yml 0000664 0000000 0000000 00000002332 14657203754 0021517 0 ustar 00root root 0000000 0000000 name: Bug Report
description: File a bug report
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a bug report. Here are some tips:
- Please only use this form for bugs in Elvish. If you need help with using Elvish, the forum or chatroom (linked from the repo README) are more suitable places.
- Please search existing issues to see if the same or similar report has been filed before.
- type: textarea
id: content
attributes:
label: What happened, and what did you expect to happen?
validations:
required: true
- type: input
id: version
attributes:
label: Output of "elvish -version"
description: |
The bug may have already been fixed. Whenever possible, please use either the latest release or the latest development build to see the bug still exists. You can still file an issue if you are running an old version and it's too hard to install a new version.
validations:
required: true
- type: checkboxes
id: terms
attributes:
label: Code of Conduct
options:
- label: I agree to follow Elvish's [Code of Conduct](https://src.elv.sh/CODE_OF_CONDUCT.md).
required: true
elvish-0.21.0/.github/ISSUE_TEMPLATE/feature_request.yml 0000664 0000000 0000000 00000002327 14657203754 0022556 0 ustar 00root root 0000000 0000000 name: Feature Request
description: File a feature request
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to file a feature request. Here are some tips:
- Please only use issues for feature requests for Elvish. If you need help with using Elvish, the forum or chatroom (linked from the repo README) is a more suitable place.
- Please search existing issues to see if the same or similar report has been filed before.
- type: textarea
id: content
attributes:
label: What new feature should Elvish have?
validations:
required: true
- type: input
id: version
attributes:
label: Output of "elvish -version"
description: |
The feature may have already been added. Whenever possible, please use the latest development build to see if it has the feature you need. You can still file an issue if you are running an old version and it's too hard to install a new version.
validations:
required: true
- type: checkboxes
id: terms
attributes:
label: Code of Conduct
options:
- label: I agree to follow Elvish's [Code of Conduct](https://src.elv.sh/CODE_OF_CONDUCT.md).
required: true
elvish-0.21.0/.github/workflows/ 0000775 0000000 0000000 00000000000 14657203754 0016476 5 ustar 00root root 0000000 0000000 elvish-0.21.0/.github/workflows/check_cirrus.yml 0000664 0000000 0000000 00000001764 14657203754 0021675 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.21.0/.github/workflows/check_website.yml 0000664 0000000 0000000 00000005736 14657203754 0022033 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@v4
- name: Compare timestamp
timeout-minutes: 30
run: |
ts=$(git show -s --format=%ct HEAD)
wait=10
while true; do
if website_ts=$(curl -sSf https://${{ matrix.host }}.elv.sh/commit-ts.txt); then
if test "$website_ts" -ge "$ts"; then
echo "website ($website_ts) >= current ($ts)"
exit 0
else
echo "website ($website_ts) < current ($ts)"
fi
else
echo "website has no commit-ts.txt yet"
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@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
# Keep this in sync with
# https://github.com/elves/up/blob/master/Dockerfile
go-version: 1.22.0
- name: Build binaries
run: go run ./cmd/elvish ./tools/buildall.elv -name elvish-HEAD -variant official ./cmd/elvish ~/elvish-bin/
- name: Upload binaries
uses: actions/upload-artifact@v4
with:
name: bin
path: ~/elvish-bin/**/*
retention-days: 7
- name: Upload binary checksums
uses: actions/upload-artifact@v4
with:
name: bin-checksums
path: ~/elvish-bin/*/*.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@v4
with:
name: bin-checksums
path: elvish-bin
- name: Check binary checksums
working-directory: elvish-bin
run: |
ret=0
for f in */elvish-HEAD.sha256sum */elvish-HEAD.exe.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.21.0/.github/workflows/ci.yml 0000664 0000000 0000000 00000010265 14657203754 0017620 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:
# The default name will include the "go-version-is" parameter, whcih is
# derived from go-version and redundant, so we supply an explicit templated
# name.
name: Run tests (${{ matrix.os }}, ${{ matrix.go-version }})
strategy:
matrix:
os: [ubuntu, macos, windows]
go-version: [1.22.x]
go-version-is: [new]
include:
# Test old supported Go version
- os: ubuntu
go-version: 1.21.x
go-version-is: [old]
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@v4
- name: Set up Go
uses: actions/setup-go@v5
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.go-version-is == 'new'
run: go test -coverprofile=cover -coverpkg=./pkg/... ./pkg/...
- name: Save test coverage
if: matrix.go-version-is == 'new'
uses: actions/upload-artifact@v4
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@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.22.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@v4
- name: Download test coverage
uses: actions/download-artifact@v4
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@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.22.x
- name: Set up Python
uses: actions/setup-python@v5
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@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.22.x
- name: Set up Python
uses: actions/setup-python@v5
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@v4
- 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.21.0/.github/workflows/docker.yml 0000664 0000000 0000000 00000001504 14657203754 0020470 0 ustar 00root root 0000000 0000000 name: Docker
on:
push:
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
elvish-0.21.0/.gitignore 0000664 0000000 0000000 00000000442 14657203754 0015071 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
/_*
/elvish
elvish-0.21.0/.vscode/ 0000775 0000000 0000000 00000000000 14657203754 0014442 5 ustar 00root root 0000000 0000000 elvish-0.21.0/.vscode/launch.json 0000664 0000000 0000000 00000000320 14657203754 0016602 0 ustar 00root root 0000000 0000000 {
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}"
],
}
]
}
elvish-0.21.0/0.21.0-release-notes.md 0000664 0000000 0000000 00000005717 14657203754 0016717 0 ustar 00root root 0000000 0000000 # Notable new features
- A new [`with`](../ref/language.html#with) command for running a lambda with
temporary assignments.
- A new [`keep-if`](../ref/builtin.html#keep-if) command.
- The [`os`](../ref/os.html) module has gained the following new commands:
`mkdir-all`, `symlink` and `rename`.
- A new [`render-styledown`](../ref/builtin.html#render-styledown) command.
- A new [`str:repeat`](../ref/str.html#str:repeat) command.
- A new [`md`](../ref/md.html) module, currently containing a single function
`md:show` for rendering Markdown in the terminal.
- On Unix, Elvish now turns off output flow control (IXON) by default, freeing
up Ctrl-S and Ctrl-Q for keybindings.
Users who require this feature can turn it back on by running `stty ixon`.
# Notable bugfixes
- The string comparison commands ` .*? ") {
t.Skip("markdown contains ")
}
if strings.Contains(original, "s` and `>=s` (but not
`!=s`) now accept any number of arguments, as they are documented to do.
- Temporary assignments now work correctly on map and list elements
([#1515](https://b.elv.sh/1515)).
- The terminal line editor is now more aggressive in suppressing compilation
errors caused by the code not being complete.
For example, during the process of typing out `echo $pid`, the editor no
longer complains that `$p` is undefined when the user has typed `echo $p`.
# Deprecations
- The implicit cd feature is now deprecated. Use `cd` or location mode
instead.
# Breaking changes
- The `eawk` command, deprecated since 0.20.0, has been removed. Use
[`re:awk`](../ref/re.html#re:awk) instead.
- Support for the legacy `~/.elvish` directory, deprecated since 0.16.0, has
been removed. For the supported directory paths, see documentation for
[the Elvish command](../ref/command.html).
- Support for the legacy temporary assignment syntax (`a=b command`),
deprecated since 0.18.0, has been removed.
Use either the [`tmp`](../ref/language.html#tmp) command (available since
0.18.0) or the [`with`](../ref/language.html#with) command (available since
this release) instead.
- The commands `!=`, `!=s` and `not-eq` now only accepts two arguments
([#1767](https://b.elv.sh/1767)).
- The commands `edit:kill-left-alnum-word` and `edit:kill-right-alnum-word`
have been renamed to `edit:kill-alnum-word-left` and
`edit:kill-alnum-word-right`, to be consistent with the documentation and
the names of other similar commands.
If you need to write code that supports both names, use `has-key` to detect
which name is available:
```elvish
fn kill-alnum-word-left {
if (has-key edit: kill-alnum-word-left~) {
edit:kill-alnum-word-left
} else {
edit:kill-left-alnum-word
}
}
```
- Using `else` without `catch` in the `try` special command is no longer
supported. The command `try { a } else { b } finally { c }` is equivalent to
just `try { a; b } finally { c }`.
elvish-0.21.0/CODE_OF_CONDUCT.md 0000664 0000000 0000000 00000001343 14657203754 0015701 0 ustar 00root root 0000000 0000000 The Elvish community follows the
[Go community code of conduct](https://go.dev/conduct).
The short version:
- Treat everyone with respect and kindness.
- Be thoughtful in how you communicate.
- Don't be destructive or inflammatory.
- If you encounter an issue, please contact xiaq via Telegram, Matrix or
Discord DM or email (xiaqqaix@gmail.com). (We don't have a team of stewards
or a committee of representatives (yet).)
Consistent with the Go community code of conduct, the following specific points
apply:
- Respect how other people choose to use Elvish and their system in general.
Elvish is opinionated software, but that doesn't mean the use cases Elvish
chooses not to support are inherently bad.
elvish-0.21.0/Dockerfile 0000664 0000000 0000000 00000000375 14657203754 0015100 0 ustar 00root root 0000000 0000000 FROM golang:1.22-alpine3.19 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.19
COPY --from=builder /go/bin/elvish /bin/elvish
CMD ["/bin/elvish"]
elvish-0.21.0/LICENSE 0000664 0000000 0000000 00000002424 14657203754 0014110 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.21.0/Makefile 0000664 0000000 0000000 00000003060 14657203754 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.elv) ./...
cd website; go test $(shell ./tools/run-race.elv) ./...
# 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.21.0/README.md 0000664 0000000 0000000 00000006776 14657203754 0014400 0 ustar 00root root 0000000 0000000 # Elvish
[](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://bbs.elv.sh)
[](https://twitter.com/ElvishShell)
[](https://t.me/+Pv5ZYgTXD-YaKwcP)
[](https://discord.gg/jrmuzRBU8D)
[](https://matrix.to/#/#users:elv.sh)
[](https://web.libera.chat/#elvish)
[](https://gitter.im/elves/elvish)
(Chat rooms are all bridged together thanks to [Matrix](https://matrix.org).)
Elvish is:
- A powerful scripting language.
- A shell with useful interactive features built-in.
- A statically linked binary for Linux, BSDs, macOS or Windows.
Elvish is pre-1.0. This means that breaking changes will still happen from time
to time, but it's stable enough for both scripting and interactive use.
## Documentation
[](https://elv.sh)
User docs are hosted on Elvish's website, [elv.sh](https://elv.sh). This
includes [how to install Elvish](https://elv.sh/get/),
[tutorials](https://elv.sh/learn/), [reference pages](https://elv.sh/ref/), and
[news](https://elv.sh/blog/).
[](./docs)
Development docs are in [./docs](./docs).
[](https://github.com/elves/awesome-elvish)
Awesome Elvish packages and tools that support Elvish.
## 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).
elvish-0.21.0/branding/ 0000775 0000000 0000000 00000000000 14657203754 0014665 5 ustar 00root root 0000000 0000000 elvish-0.21.0/branding/forum-banner-dark.svg 0000664 0000000 0000000 00000007567 14657203754 0020737 0 ustar 00root root 0000000 0000000
elvish-0.21.0/branding/forum-banner-light.svg 0000664 0000000 0000000 00000007571 14657203754 0021120 0 ustar 00root root 0000000 0000000
elvish-0.21.0/branding/forum-logo.svg 0000664 0000000 0000000 00000006024 14657203754 0017476 0 ustar 00root root 0000000 0000000
elvish-0.21.0/branding/logo-full-bleed.svg 0000664 0000000 0000000 00000005025 14657203754 0020361 0 ustar 00root root 0000000 0000000
elvish-0.21.0/branding/logo.svg 0000664 0000000 0000000 00000004761 14657203754 0016356 0 ustar 00root root 0000000 0000000
elvish-0.21.0/cmd/ 0000775 0000000 0000000 00000000000 14657203754 0013644 5 ustar 00root root 0000000 0000000 elvish-0.21.0/cmd/elvish/ 0000775 0000000 0000000 00000000000 14657203754 0015136 5 ustar 00root root 0000000 0000000 elvish-0.21.0/cmd/elvish/main.go 0000664 0000000 0000000 00000001253 14657203754 0016412 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.21.0/cmd/elvmdfmt/ 0000775 0000000 0000000 00000000000 14657203754 0015462 5 ustar 00root root 0000000 0000000 elvish-0.21.0/cmd/elvmdfmt/main.go 0000664 0000000 0000000 00000004210 14657203754 0016732 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.21.0/cmd/nodaemon/ 0000775 0000000 0000000 00000000000 14657203754 0015444 5 ustar 00root root 0000000 0000000 elvish-0.21.0/cmd/nodaemon/elvish/ 0000775 0000000 0000000 00000000000 14657203754 0016736 5 ustar 00root root 0000000 0000000 elvish-0.21.0/cmd/nodaemon/elvish/main.go 0000664 0000000 0000000 00000000623 14657203754 0020212 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.21.0/cmd/withpprof/ 0000775 0000000 0000000 00000000000 14657203754 0015666 5 ustar 00root root 0000000 0000000 elvish-0.21.0/cmd/withpprof/elvish/ 0000775 0000000 0000000 00000000000 14657203754 0017160 5 ustar 00root root 0000000 0000000 elvish-0.21.0/cmd/withpprof/elvish/main.go 0000664 0000000 0000000 00000001010 14657203754 0020423 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.21.0/docs/ 0000775 0000000 0000000 00000000000 14657203754 0014031 5 ustar 00root root 0000000 0000000 elvish-0.21.0/docs/README.md 0000664 0000000 0000000 00000001551 14657203754 0015312 0 ustar 00root root 0000000 0000000 💡 Tip: If you are looking for docs for Elvish users, like tutorials and
reference pages, refer to Elvish's website [elv.sh](https://elv.sh) instead.
This directory contains developer documentation:
- 🏗️ [Building Elvish from source](building.md)
- 📦 [Packaging Elvish](packaging.md)
- 🔑 [Security policy](security.md)
- 🧩 [Using Elvish as a library](elvish-as-library.md)
If you'd like to contribute to Elvish:
- 🏢
[Architecture overview](https://pkg.go.dev/src.elv.sh@master/docs/architecture)
This document is written as a godoc comment. You can also read the Go source
[architecture/doc.go](architecture/doc.go).
- 👋 [Process for contributing to Elvish](contributing.md)
- 🧪 [Testing changes](testing.md)
- 📚 [Documenting changes](documenting.md)
- 🔧 [Common development workflows](workflows.md)
elvish-0.21.0/docs/architecture/ 0000775 0000000 0000000 00000000000 14657203754 0016513 5 ustar 00root root 0000000 0000000 elvish-0.21.0/docs/architecture/doc.go 0000664 0000000 0000000 00000015306 14657203754 0017614 0 ustar 00root root 0000000 0000000 /*
# Overview
This file documents how Elvish's codebase is structured on a high level. You
can read it either in a code editor, or in a godoc viewer such as
https://pkg.go.dev/src.elv.sh@master/docs/architecture.
Elvish is a Go project. If you are not familiar with how Go code is
organized, start with [how to write Go code].
Go code in the Elvish repo lives under two directories:
- The cmd directory contains Elvish's entrypoints, but it contains very little code.
- The pkg directory has most of Elvish's Go code. It has a lot of
subdirectories, so it can be a bit hard to find your bearing just by
exploring the file tree.
We will cover the cmd directory first, and then focus on the most important
subdirectories under pkg.
The Elvish repo also contains other directories. They are not technically
part of the Go program, so we won't cover them here. Read their respective
README files to learn more.
# Module, package and symbol names
Elvish's module name is [src.elv.sh]. You can think of it as an alias to where
the code is actually hosted (currently [github.com/elves/elvish]).
The import paths of all the packages start with the module name [src.elv.sh].
For example, the import path of the package in pkg/parse is
[src.elv.sh/pkg/parse].
When referring to a symbol from a package, we'll use just the last component of
the package's import path. For example, the Evaler type from the
[src.elv.sh/pkg/eval] package is simply [eval.Evaler]. (This is consistent with
Go's syntax.)
# Entrypoints (cmd/elvish and pkg/prog)
The default entrypoint of Elvish is [src.elv.sh/cmd/elvish]. It has a main
function that does the following:
- Assemble a "composite program" from multiple subprograms, most
notably [shell.Program].
- Call [prog.Run].
You can read about the advantage of this approach in the godoc of
[src.elv.sh/pkg/prog].
There are other main packages, like [src.elv.sh/cmd/withpprof/elvish]. They
follow the same structure and only differ in which subprograms they include.
# The shell subprogram (pkg/shell)
The shell subprogram has two slightly different "modes", interactive and
non-interactive, depending on the command-line arguments. The doc for
[shell.Program] contains more details.
In both modes, the shell subprogram uses the interpreter implemented in
[src.elv.sh/pkg/eval] to evaluate code.
In interactive mode, the shell also uses the line editor implemented in
[src.elv.sh/pkg/edit] to read commands interactively. Some features of the
editor depend on persistent storage; the shell subprogram also takes care of
initializing that, using [src.elv.sh/pkg/daemon].
# The interpreter (pkg/eval)
The [src.elv.sh/pkg/eval] package is perhaps the most important package in
Elvish, as it implements the Elvish language and the builtin module.
The interpreter is represented by [eval.Evaler], which is created with
[eval.NewEvaler]. The method [eval.Evaler.Eval] (yes, that's 3 "evals"s)
evaluates Elvish code, and does so in several steps:
1. Invoke the parser to get an AST.
2. Compile the AST into an "operation tree".
3. Run the operation tree.
This approach is chosen mainly for its simplicity. It's probably not very
performant.
The compilation of each AST node into its corresponding operation node, as well
as how each operation node runs, is defined in the several compile_*.go files.
These files are where most of the language semantics is implemented.
Another sizable chunk of this package is the various builtin_fn_*.go files,
which implement functions of the builtin module. These may be moved to a
different package in future.
Some other packages important for the interpreter are:
- [src.elv.sh/pkg/eval/vals] implements a standard set of operations for
Elvish values.
- [src.elv.sh/pkg/persistent] implements Elvish's lists and maps, modeled
after [Clojure's vectors and maps].
- Subdirectories of [src.elv.sh/pkg/mods] implement the various builtin
modules.
# The parser (pkg/parse)
The [src.elv.sh/pkg/parse] package implements parsing of Elvish code, with the
[parse.Parse] as the entrypoint.
The parsing algorithm is a handwritten [recursive descent] one, with the
slightly unusual property that there's no separate tokenization phase. Read the
package's godoc for more details.
# The editor (pkg/edit)
The [src.elv.sh/pkg/edit] package contains Elvish's interactive line editor,
represented by [edit.Editor]. The traditional term "line editor" is a bit of a
misnomer; modern line editors (including Elvish's) are similar to full-blown TUI
applications like Vim, except that they usually restrict themselves to the last
N lines of the terminal rather than the entire screen.
The editor is built on top of the more low-level [src.elv.sh/pkg/cli] package
(which is also a bit of a misnomer), in particular the [cli.App] type.
The entire TUI stack is due for a rewrite soon.
The editor relies on persistent storage for features like the directory history
and the command history. As mentioned above, the initialization of the storage
is done in pkg/shell, using pkg/daemon.
# The storage daemon (pkg/daemon)
Support for persistent storage is is currently provided by a storage daemon. The
[src.elv.sh/pkg/daemon] packages implements two things:
- A subprogram implementing the storage daemon ([daemon.Program]).
- A client to talk to the daemon (returned by [daemon.Activate]).
The daemon is launched and terminated on demand:
- The first interactive Elvish shell launches the daemon.
- Subsequent interactive shells talk to the same daemon.
- When the last interactive Elvish shell quits, the daemon also quits.
Internally, the daemon uses [bbolt] as the database engine.
In future (subject to evaluation) Elvish might get a custom database, and the
daemon might go away.
# Closing remarks
This should have given you a rough idea of the most important bits of Elvish's
implementation. The implementation prioritizes readability, and most exported
symbols are documented, so feel free to dive into the source code!
If you have questions, feel free to ask in the user group or DM xiaq.
[how to write Go code]: https://go.dev/doc/code
[github.com/elves/elvish]: https://github.com/elves/elvish
[src.elv.sh]: https://src.elv.sh
[Clojure's vectors and maps]: https://clojure.org/reference/data_structures
[recursive descent]: https://en.wikipedia.org/wiki/Recursive_descent_parser
[bbolt]: https://github.com/etcd-io/bbolt
*/
package architecture
import (
"src.elv.sh/pkg/cli"
"src.elv.sh/pkg/daemon"
"src.elv.sh/pkg/edit"
"src.elv.sh/pkg/eval"
"src.elv.sh/pkg/parse"
"src.elv.sh/pkg/prog"
"src.elv.sh/pkg/shell"
)
var (
_ = new(shell.Program)
_ = prog.Run
_ = eval.NewEvaler
_ = (*eval.Evaler).Eval
_ = parse.Parse
_ = new(edit.Editor)
_ = new(cli.App)
_ = new(daemon.Program)
_ = daemon.Activate
)
elvish-0.21.0/docs/building.md 0000664 0000000 0000000 00000005773 14657203754 0016164 0 ustar 00root root 0000000 0000000 # Building Elvish from source
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.21.0.
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 an alternative entrypoint
In additional to `src.elv.sh/cmd/elvish` (which corresponds to the
[`cmd/elvish`](./cmd/elvish) directory in the repo), there are a few alternative
entrypoints, all named liked `cmd/*/elvish`, with slightly different feature
sets. (From the perspective of Go, these are just different `main` packages.)
For example, install the `cmd/withpprof/elvish` entrypoint to get
[profiling support](https://pkg.go.dev/runtime/pprof) (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).
elvish-0.21.0/docs/contributing.md 0000664 0000000 0000000 00000001434 14657203754 0017064 0 ustar 00root root 0000000 0000000 # Process for contributing to Elvish
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.
## Licensing
By contributing, you agree to license your code under the same license as
existing source code of Elvish. See the [README](../README.md) at the project
root for the license.
elvish-0.21.0/docs/documenting.md 0000664 0000000 0000000 00000004573 14657203754 0016700 0 ustar 00root root 0000000 0000000 # 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() { }
```
elvish-0.21.0/docs/elvish-as-library.md 0000664 0000000 0000000 00000002023 14657203754 0017705 0 ustar 00root root 0000000 0000000 # Using Elvish as a library
Elvish's implementation is structured as a collection of Go packages with
well-documented internal APIs, so it's possible to use the parts you're
interested in as a Go library.
- Most likely, you'll want to use Elvish's interpreter. The examples for the
[`Evaler.Eval` method](https://pkg.go.dev/src.elv.sh@master/pkg/eval#Evaler.Eval)
should give you a good starting point.
- For a general overview of how Elvish's code is structured, read the
[architecture overview](https://pkg.go.dev/src.elv.sh@master/docs/architecture).
However, beware that Elvish promises no backward compatibility in its Go API.
The internal API surface is large, and will change from time to time as Elvish's
implementation gets refactored.
For now, this is consistent with Go's semantic versioning rules as Elvish is
pre-1.0. When Elvish 1.0 is eventually released, all the internal libraries will
likely be moved into an `internal` directory, with a small part of the API
exposed via facades in the `pkg` directory.
elvish-0.21.0/docs/packaging.md 0000664 0000000 0000000 00000001414 14657203754 0016277 0 ustar 00root root 0000000 0000000 # Packaging Elvish
The main package of Elvish is `cmd/elvish`, and you can build it like any other
Go application.
## Enhancing version information
You can set some variables in the `src.elv.sh/pkg/buildinfo` package using
linker flags to enhance the Elvish's version information. See the
[package's API doc](https://pkg.go.dev/src.elv.sh@master/pkg/buildinfo) for
details.
They don't affect any other aspect of Elvish's behavior, so it's infeasible to
pass those linker flags, it's fine to leave them as is.
**Note**: The names and usage of these variables have changed several time in
Elvish's history. If your build script has `-ldflags '-X $symbol=$value'` where
`$symbol` is not documented in the linked API doc, those flags no longer do
anything and should be removed.
elvish-0.21.0/docs/security.md 0000664 0000000 0000000 00000000713 14657203754 0016223 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.21.0/docs/testing.md 0000664 0000000 0000000 00000006253 14657203754 0016036 0 ustar 00root root 0000000 0000000 # 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.
### Transcript tests
Most tests against Elvish modules are written in `.elvts` files, which mimic
transcripts of Elvish REPL sessions. See
https://pkg.go.dev/src.elv.sh@master/pkg/transcript for the format of transcript
files, and https://pkg.go.dev/src.elv.sh@master/pkg/eval/evaltest for details
specific to using them as tests.
If you use VS Code, the official Elvish extension allows you to simply press
Alt-Enter to update the output of transcripts (specifically, the
output for the code block the cursor is in). This means that you can author
transcript tests entirely within the editor, instead of manually writing out the
expected output or copy-pasting outputs from an actual REPL.
**Note**: The functionality of the VS Code plugin is based on a very simple
protocol and can be easily implemented for other editors. The protocol is
documented in the godoc for the `evaltest` package (see link below), and you can
also take a look `vscode/src/extension.ts` for the client implementation in the
VS Code extension.
### 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
}
```
elvish-0.21.0/docs/workflows.md 0000664 0000000 0000000 00000007277 14657203754 0016425 0 ustar 00root root 0000000 0000000 # Common development workflows
The [`Makefile`](Makefile) encapsulates common development workflows:
- Use `make fmt` to [format files](#formatting-files).
- Use `make test` to [run tests](./testing.md).
- 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.
## 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.21.0/go.mod 0000664 0000000 0000000 00000000444 14657203754 0014211 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.9
golang.org/x/sync v0.6.0
golang.org/x/sys v0.17.0
pkg.nimblebun.works/go-lsp v1.1.0
)
go 1.21
elvish-0.21.0/go.sum 0000664 0000000 0000000 00000004276 14657203754 0014245 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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
go.etcd.io/bbolt v1.3.9 h1:8x7aARPEXiXbHmtUwAIv7eV2fQFHrLLavdiJ3uzJXoI=
go.etcd.io/bbolt v1.3.9/go.mod h1:zaO32+Ti0PK1ivdPtgMESzuzL2VPoIG1PCQNvOdo/dE=
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.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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.21.0/pkg/ 0000775 0000000 0000000 00000000000 14657203754 0013662 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/buildinfo/ 0000775 0000000 0000000 00000000000 14657203754 0015635 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/buildinfo/buildinfo.go 0000664 0000000 0000000 00000013713 14657203754 0020144 0 ustar 00root root 0000000 0000000 // Package buildinfo contains build information.
//
// Exported string variables may be set during compilation using a linker flag
// like this:
//
// go build -ldflags '-X src.elv.sh/pkg/buildinfo.NAME=VALUE' ./cmd/elvish
//
// This mechanism can be used by packagers to enhance Elvish's version
// information. The variables that can be set are documented below.
//
// # BuildVariant
//
// [BuildVariant], if non-empty, gets appended to the version string along with
// a "+" prefix. It should be set to a value identifying the build environment.
//
// Typically, this should be the name of the software distribution that is
// packaging Elvish, possibly plus the revision of the package. Example for
// revision 1 of a Debian package:
//
// go build -ldflags '-X src.elv.sh/pkg/buildinfo.BuildVariant=deb1' ./cmd/elvish
//
// Supposing that [VersionBase] is "0.233.0", this causes "elvish -version" to
// print out "0.233.0+deb1".
//
// The value "official" is reserved for official binaries linked from
// https://elv.sh/get. Do not use it unless you can ensure that your build is
// bit-to-bit identical with the official binaries and you are committing to
// maintaining that property.
//
// # VCSOverride
//
// On development commits, Elvish uses the information from Git to generate a
// version string like (following the format of [Go module pseudo-versions]):
//
// 0.234.0-dev.0.20220320172241-5dc8c02a32cf
//
// where 20220320172241 is the commit time (in YYYYMMDDHHMMSS) and 5dc8c02a32cf
// is the first 12 digits of the commit hash.
//
// If this information is not available when Elvish was built - for example, if
// the build works from an archive of the commit rather than a Git checkout -
// the version string will instead look like this:
//
// 0.234.0-dev.unknown
//
// In that case, [VCSOverride] can be set to to supply the $time-$commit
// information:
//
// go build -ldflags '-X src.elv.sh/pkg/buildinfo.VCSOverride=20220320172241-5dc8c02a32cf' ./cmd/elvish
//
// Setting this variable is only necessary when building development commits and
// the VCS information is not available.
//
// [Go module pseudo-versions]: https://go.dev/ref/mod#pseudo-versions
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 release branches, it identifies the exact version of the commit, and
// is consistent with the Git tag. For example, at tag v0.233.0, this will
// be "0.233.0".
//
// - On development branches, it identifies the first version of the next
// release branch. For example, after releases for 0.233.x has been branched
// but before 0.234.x is branched, this will be "0.244.0". The full version
// string will be augmented with VCS information (see [VCSOverride]).
//
// In both cases, the full version is also augmented with the [BuildVariant].
const VersionBase = "0.21.0"
// VCSOverride may be set to identify the commit of development builds when that
// information is not available during build time. It has no effect on release
// branches. See the package godoc for more details.
var VCSOverride string
// BuildVariant may be set to identify the build environment. See the package
// godoc for more details.
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.21.0/pkg/buildinfo/buildinfo_test.elvts 0000664 0000000 0000000 00000001444 14657203754 0021731 0 ustar 00root root 0000000 0000000 //each:elvish-in-global
////////////////////
# program behavior #
////////////////////
// Tests in this section are necessarily tautological, since we can't hardcode
// the actual versions in the tests. Instead, all we do is verifying that the
// output from the flags are consistent with the information in $buildinfo.
## -version ##
~> elvish -version | eq (one) $buildinfo[version]
▶ $true
~> elvish -version -json | eq (one) (to-json [$buildinfo[version]])
▶ $true
## -buildinfo ##
~> elvish -buildinfo | eq (slurp) "Version: "$buildinfo[version]"\nGo version: "$buildinfo[go-version]"\n"
▶ $true
~> elvish -buildinfo -json | eq (one) (to-json [$buildinfo])
▶ $true
## exits with NextProgram if neither flag is given ##
~> elvish
[stderr] internal error: no suitable subprogram
[exit] 2
elvish-0.21.0/pkg/buildinfo/buildinfo_test.go 0000664 0000000 0000000 00000005002 14657203754 0021173 0 ustar 00root root 0000000 0000000 package buildinfo
import (
"runtime/debug"
"testing"
"src.elv.sh/pkg/testutil"
)
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)
}
}
elvish-0.21.0/pkg/buildinfo/transcripts_test.go 0000664 0000000 0000000 00000000537 14657203754 0021604 0 ustar 00root root 0000000 0000000 package buildinfo_test
import (
"embed"
"testing"
"src.elv.sh/pkg/buildinfo"
"src.elv.sh/pkg/eval/evaltest"
"src.elv.sh/pkg/prog/progtest"
)
//go:embed *.elvts
var transcripts embed.FS
func TestTranscripts(t *testing.T) {
evaltest.TestTranscriptsInFS(t, transcripts,
"elvish-in-global", progtest.ElvishInGlobal(&buildinfo.Program{}),
)
}
elvish-0.21.0/pkg/cli/ 0000775 0000000 0000000 00000000000 14657203754 0014431 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/cli/app.go 0000664 0000000 0000000 00000030111 14657203754 0015534 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.21.0/pkg/cli/app_spec.go 0000664 0000000 0000000 00000004034 14657203754 0016553 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.21.0/pkg/cli/app_test.go 0000664 0000000 0000000 00000036237 14657203754 0016612 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.21.0/pkg/cli/clitest/ 0000775 0000000 0000000 00000000000 14657203754 0016100 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/cli/clitest/apptest.go 0000664 0000000 0000000 00000006220 14657203754 0020107 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.21.0/pkg/cli/clitest/apptest_test.go 0000664 0000000 0000000 00000001662 14657203754 0021153 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.21.0/pkg/cli/clitest/fake_tty.go 0000664 0000000 0000000 00000016725 14657203754 0020250 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.21.0/pkg/cli/clitest/fake_tty_test.go 0000664 0000000 0000000 00000007776 14657203754 0021315 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.21.0/pkg/cli/examples/ 0000775 0000000 0000000 00000000000 14657203754 0016247 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/cli/examples/e3bc/ 0000775 0000000 0000000 00000000000 14657203754 0017063 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/cli/examples/e3bc/bc/ 0000775 0000000 0000000 00000000000 14657203754 0017447 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/cli/examples/e3bc/bc/bc.go 0000664 0000000 0000000 00000001740 14657203754 0020364 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.21.0/pkg/cli/examples/e3bc/completion.go 0000664 0000000 0000000 00000001137 14657203754 0021565 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.21.0/pkg/cli/examples/e3bc/main.go 0000664 0000000 0000000 00000003301 14657203754 0020333 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.21.0/pkg/cli/examples/nav/ 0000775 0000000 0000000 00000000000 14657203754 0017033 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/cli/examples/nav/main.go 0000664 0000000 0000000 00000001003 14657203754 0020300 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.21.0/pkg/cli/examples/widget/ 0000775 0000000 0000000 00000000000 14657203754 0017532 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/cli/examples/widget/main.go 0000664 0000000 0000000 00000003077 14657203754 0021014 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.21.0/pkg/cli/examples/win_tty/ 0000775 0000000 0000000 00000000000 14657203754 0017744 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/cli/examples/win_tty/main_windows.go 0000664 0000000 0000000 00000004362 14657203754 0022776 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.21.0/pkg/cli/histutil/ 0000775 0000000 0000000 00000000000 14657203754 0016276 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/cli/histutil/db.go 0000664 0000000 0000000 00000000552 14657203754 0017214 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.21.0/pkg/cli/histutil/db_store.go 0000664 0000000 0000000 00000003047 14657203754 0020432 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.21.0/pkg/cli/histutil/db_store_test.go 0000664 0000000 0000000 00000001753 14657203754 0021473 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.21.0/pkg/cli/histutil/dedup_cursor.go 0000664 0000000 0000000 00000001677 14657203754 0021336 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.21.0/pkg/cli/histutil/dedup_cursor_test.go 0000664 0000000 0000000 00000001240 14657203754 0022357 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.21.0/pkg/cli/histutil/doc.go 0000664 0000000 0000000 00000000132 14657203754 0017366 0 ustar 00root root 0000000 0000000 // Package histutil provides utilities for working with command history.
package histutil
elvish-0.21.0/pkg/cli/histutil/hybrid_store.go 0000664 0000000 0000000 00000003213 14657203754 0021321 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.21.0/pkg/cli/histutil/hybrid_store_test.go 0000664 0000000 0000000 00000012343 14657203754 0022364 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.21.0/pkg/cli/histutil/mem_store.go 0000664 0000000 0000000 00000002540 14657203754 0020620 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.21.0/pkg/cli/histutil/mem_store_test.go 0000664 0000000 0000000 00000000500 14657203754 0021651 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.21.0/pkg/cli/histutil/store.go 0000664 0000000 0000000 00000002276 14657203754 0017770 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.21.0/pkg/cli/histutil/test_db.go 0000664 0000000 0000000 00000004024 14657203754 0020251 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.21.0/pkg/cli/loop.go 0000664 0000000 0000000 00000007060 14657203754 0015734 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.21.0/pkg/cli/loop_test.go 0000664 0000000 0000000 00000007743 14657203754 0017003 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.21.0/pkg/cli/lscolors/ 0000775 0000000 0000000 00000000000 14657203754 0016271 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/cli/lscolors/feature.go 0000664 0000000 0000000 00000005036 14657203754 0020257 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.21.0/pkg/cli/lscolors/feature_nonunix_test.go 0000664 0000000 0000000 00000000344 14657203754 0023071 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.21.0/pkg/cli/lscolors/feature_test.go 0000664 0000000 0000000 00000010375 14657203754 0021320 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.21.0/pkg/cli/lscolors/feature_unix_test.go 0000664 0000000 0000000 00000000226 14657203754 0022355 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.21.0/pkg/cli/lscolors/lscolors.go 0000664 0000000 0000000 00000011013 14657203754 0020454 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.21.0/pkg/cli/lscolors/lscolors_test.go 0000664 0000000 0000000 00000002261 14657203754 0021520 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.21.0/pkg/cli/lscolors/stat_notsolaris.go 0000664 0000000 0000000 00000000222 14657203754 0022044 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.21.0/pkg/cli/lscolors/stat_solaris.go 0000664 0000000 0000000 00000000316 14657203754 0021327 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.21.0/pkg/cli/lscolors/stat_unix.go 0000664 0000000 0000000 00000001043 14657203754 0020634 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.21.0/pkg/cli/lscolors/stat_windows.go 0000664 0000000 0000000 00000000343 14657203754 0021345 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.21.0/pkg/cli/modes/ 0000775 0000000 0000000 00000000000 14657203754 0015540 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/cli/modes/completion.go 0000664 0000000 0000000 00000005074 14657203754 0020246 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.21.0/pkg/cli/modes/completion_test.go 0000664 0000000 0000000 00000003351 14657203754 0021301 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.21.0/pkg/cli/modes/filter_spec.go 0000664 0000000 0000000 00000001141 14657203754 0020363 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.21.0/pkg/cli/modes/histlist.go 0000664 0000000 0000000 00000005414 14657203754 0017736 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.21.0/pkg/cli/modes/histlist_test.go 0000664 0000000 0000000 00000007547 14657203754 0021006 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.21.0/pkg/cli/modes/histwalk.go 0000664 0000000 0000000 00000005747 14657203754 0017732 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.21.0/pkg/cli/modes/histwalk_test.go 0000664 0000000 0000000 00000006107 14657203754 0020760 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.21.0/pkg/cli/modes/instant.go 0000664 0000000 0000000 00000004456 14657203754 0017560 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.21.0/pkg/cli/modes/instant_test.go 0000664 0000000 0000000 00000003137 14657203754 0020612 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.21.0/pkg/cli/modes/lastcmd.go 0000664 0000000 0000000 00000006203 14657203754 0017517 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.21.0/pkg/cli/modes/lastcmd_test.go 0000664 0000000 0000000 00000005215 14657203754 0020560 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.21.0/pkg/cli/modes/listing.go 0000664 0000000 0000000 00000004417 14657203754 0017546 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.21.0/pkg/cli/modes/listing_test.go 0000664 0000000 0000000 00000004364 14657203754 0020606 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.21.0/pkg/cli/modes/location.go 0000664 0000000 0000000 00000011332 14657203754 0017677 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.21.0/pkg/cli/modes/location_test.go 0000664 0000000 0000000 00000013553 14657203754 0020745 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.21.0/pkg/cli/modes/mode.go 0000664 0000000 0000000 00000002523 14657203754 0017015 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.21.0/pkg/cli/modes/mode_test.go 0000664 0000000 0000000 00000002412 14657203754 0020051 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.21.0/pkg/cli/modes/navigation.go 0000664 0000000 0000000 00000021545 14657203754 0020235 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.21.0/pkg/cli/modes/navigation_fs.go 0000664 0000000 0000000 00000011654 14657203754 0020725 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.21.0/pkg/cli/modes/navigation_fs_test.go 0000664 0000000 0000000 00000004627 14657203754 0021766 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.21.0/pkg/cli/modes/navigation_test.go 0000664 0000000 0000000 00000022742 14657203754 0021274 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.21.0/pkg/cli/modes/navigation_unix_test.go 0000664 0000000 0000000 00000001553 14657203754 0022334 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.21.0/pkg/cli/modes/stub.go 0000664 0000000 0000000 00000002131 14657203754 0017041 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.21.0/pkg/cli/modes/stub_test.go 0000664 0000000 0000000 00000001463 14657203754 0020107 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.21.0/pkg/cli/prompt/ 0000775 0000000 0000000 00000000000 14657203754 0015752 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/cli/prompt/prompt.go 0000664 0000000 0000000 00000006624 14657203754 0017632 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.21.0/pkg/cli/prompt/prompt_test.go 0000664 0000000 0000000 00000010437 14657203754 0020666 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.21.0/pkg/cli/term/ 0000775 0000000 0000000 00000000000 14657203754 0015400 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/cli/term/buffer.go 0000664 0000000 0000000 00000011011 14657203754 0017172 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.21.0/pkg/cli/term/buffer_builder.go 0000664 0000000 0000000 00000007741 14657203754 0020717 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.21.0/pkg/cli/term/buffer_builder_test.go 0000664 0000000 0000000 00000006267 14657203754 0021760 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{" ", ""}}}}},
}
// TestBufferBuilderWrites 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.21.0/pkg/cli/term/buffer_test.go 0000664 0000000 0000000 00000017715 14657203754 0020252 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.21.0/pkg/cli/term/event.go 0000664 0000000 0000000 00000003020 14657203754 0017043 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.21.0/pkg/cli/term/file_reader_unix.go 0000664 0000000 0000000 00000003111 14657203754 0021227 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.21.0/pkg/cli/term/file_reader_unix_test.go 0000664 0000000 0000000 00000003204 14657203754 0022271 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.21.0/pkg/cli/term/read_rune.go 0000664 0000000 0000000 00000002026 14657203754 0017673 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.21.0/pkg/cli/term/read_rune_test.go 0000664 0000000 0000000 00000002257 14657203754 0020740 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.21.0/pkg/cli/term/reader.go 0000664 0000000 0000000 00000002733 14657203754 0017176 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.21.0/pkg/cli/term/reader_test.go 0000664 0000000 0000000 00000000477 14657203754 0020240 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.21.0/pkg/cli/term/reader_unix.go 0000664 0000000 0000000 00000027474 14657203754 0020252 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.21.0/pkg/cli/term/reader_unix_test.go 0000664 0000000 0000000 00000013560 14657203754 0021300 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.21.0/pkg/cli/term/reader_windows.go 0000664 0000000 0000000 00000014150 14657203754 0020744 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.21.0/pkg/cli/term/reader_windows_test.go 0000664 0000000 0000000 00000003212 14657203754 0022000 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.21.0/pkg/cli/term/setup.go 0000664 0000000 0000000 00000006321 14657203754 0017071 0 ustar 00root root 0000000 0000000 package term
import (
"fmt"
"os"
"src.elv.sh/pkg/sys"
"src.elv.sh/pkg/wcwidth"
)
// SetupForTUIOnce sets up the terminal once for a whole interactive session. It
// returns a function that can be used to restore the original terminal config.
func SetupForTUIOnce(in, out *os.File) func() {
return setupForTUIOnce(in, out)
}
// SetupForTUI sets up the terminal so that it is suitable for the TUI
// application (as implemented by pkg/cli). It returns a function that can be
// used to restore the original terminal config.
func SetupForTUI(in, out *os.File) (func() error, error) {
return setupForTUI(in, out)
}
// SetupForEval sets up the terminal for evaluating Elvish code, whether or not
// we are in an interactive session. 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.21.0/pkg/cli/term/setup_unix.go 0000664 0000000 0000000 00000004166 14657203754 0020141 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 setupForTUIOnce(in, _ *os.File) func() {
fd := int(in.Fd())
term, err := eunix.TermiosForFd(fd)
if err != nil {
return func() {}
}
savedTermios := term.Copy()
// Turning off IXON frees up Ctrl-Q and Ctrl-S for keybindings, but it's not
// actually necessary for Elvish to function.
//
// We do this in SetupForTUIOnce rather than SetupForTUI so that the user
// can still use "stty ixon" to turn it on if they wish.
//
// Other "nice to have" terminal setups should go here as well.
term.SetIXON(false)
term.ApplyToFd(fd)
return func() { savedTermios.ApplyToFd(fd) }
}
func setupForTUI(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.21.0/pkg/cli/term/setup_unix_test.go 0000664 0000000 0000000 00000001427 14657203754 0021175 0 ustar 00root root 0000000 0000000 //go:build unix
package term
import (
"os"
"testing"
"github.com/creack/pty"
)
func TestSetupForTUIOnce(t *testing.T) {
_, tty := setupPTY(t)
setupForTUIOnce(tty, tty)
// TODO: Test whether the interesting flags in the termios were indeed set.
}
func TestSetupForTUI(t *testing.T) {
_, tty := setupPTY(t)
_, err := setupForTUI(tty, tty)
if err != nil {
t.Errorf("setupForTUI returns an error")
}
// TODO: Test whether the interesting flags in the termios were indeed set.
// termios, err := eunix.TermiosForFd(int(tty.Fd()))
}
func setupPTY(t *testing.T) (ptySide, ttySide *os.File) {
t.Helper()
ptySide, ttySide, err := pty.Open()
if err != nil {
t.Skip("cannot open pty")
}
t.Cleanup(func() {
ptySide.Close()
ttySide.Close()
})
return ptySide, ttySide
}
elvish-0.21.0/pkg/cli/term/setup_windows.go 0000664 0000000 0000000 00000003033 14657203754 0020640 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 setupForTUIOnce(_, _ *os.File) func() {
return func() {}
}
func setupForTUI(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)
}
// We need ENABLE_VIRTUAL_TERMINAL_PROCESSING for styled text to function. This
// includes texts created by the user (with the "styled" builtin) or by Elvish
// itself (like exception stack traces).
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.21.0/pkg/cli/term/setup_windows_test.go 0000664 0000000 0000000 00000003243 14657203754 0021702 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.21.0/pkg/cli/term/term.go 0000664 0000000 0000000 00000000240 14657203754 0016672 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.21.0/pkg/cli/term/writer.go 0000664 0000000 0000000 00000012023 14657203754 0017241 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.21.0/pkg/cli/term/writer_test.go 0000664 0000000 0000000 00000000751 14657203754 0020305 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.21.0/pkg/cli/tk/ 0000775 0000000 0000000 00000000000 14657203754 0015047 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/cli/tk/codearea.go 0000664 0000000 0000000 00000026374 14657203754 0017155 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.21.0/pkg/cli/tk/codearea_render.go 0000664 0000000 0000000 00000005461 14657203754 0020506 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.21.0/pkg/cli/tk/codearea_test.go 0000664 0000000 0000000 00000041233 14657203754 0020203 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.21.0/pkg/cli/tk/colview.go 0000664 0000000 0000000 00000012305 14657203754 0017047 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.21.0/pkg/cli/tk/colview_test.go 0000664 0000000 0000000 00000006505 14657203754 0020113 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.21.0/pkg/cli/tk/combobox.go 0000664 0000000 0000000 00000004337 14657203754 0017215 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 {
// TODO: Test the behavior of Render when height is very small
// (https://b.elv.sh/1820)
if height == 1 {
return w.listBox.Render(width, height)
}
buf := w.codeArea.Render(width, height-1)
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.21.0/pkg/cli/tk/combobox_test.go 0000664 0000000 0000000 00000005351 14657203754 0020251 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.21.0/pkg/cli/tk/empty.go 0000664 0000000 0000000 00000000772 14657203754 0016542 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.21.0/pkg/cli/tk/label.go 0000664 0000000 0000000 00000001471 14657203754 0016460 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.21.0/pkg/cli/tk/layout_test.go 0000664 0000000 0000000 00000004711 14657203754 0017755 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.21.0/pkg/cli/tk/listbox.go 0000664 0000000 0000000 00000025433 14657203754 0017071 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.21.0/pkg/cli/tk/listbox_state.go 0000664 0000000 0000000 00000002331 14657203754 0020261 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.21.0/pkg/cli/tk/listbox_test.go 0000664 0000000 0000000 00000033107 14657203754 0020125 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.21.0/pkg/cli/tk/listbox_window.go 0000664 0000000 0000000 00000012014 14657203754 0020447 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.21.0/pkg/cli/tk/listbox_window_test.go 0000664 0000000 0000000 00000010544 14657203754 0021514 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.21.0/pkg/cli/tk/scrollbar.go 0000664 0000000 0000000 00000004053 14657203754 0017363 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.21.0/pkg/cli/tk/textview.go 0000664 0000000 0000000 00000006210 14657203754 0017254 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.21.0/pkg/cli/tk/textview_test.go 0000664 0000000 0000000 00000007043 14657203754 0020320 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.21.0/pkg/cli/tk/utils_test.go 0000664 0000000 0000000 00000006475 14657203754 0017611 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.21.0/pkg/cli/tk/widget.go 0000664 0000000 0000000 00000003633 14657203754 0016666 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.21.0/pkg/cli/tk/widget_test.go 0000664 0000000 0000000 00000004047 14657203754 0017725 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.21.0/pkg/cli/tty.go 0000664 0000000 0000000 00000005307 14657203754 0015605 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.SetupForTUI(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.21.0/pkg/cli/tty_unix_test.go 0000664 0000000 0000000 00000001627 14657203754 0017710 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.21.0/pkg/daemon/ 0000775 0000000 0000000 00000000000 14657203754 0015125 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/daemon/activate.go 0000664 0000000 0000000 00000014062 14657203754 0017257 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.21.0/pkg/daemon/activate_test.go 0000664 0000000 0000000 00000005772 14657203754 0020326 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.21.0/pkg/daemon/activate_unix_test.go 0000664 0000000 0000000 00000002743 14657203754 0021364 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.21.0/pkg/daemon/client.go 0000664 0000000 0000000 00000010544 14657203754 0016736 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.21.0/pkg/daemon/daemondefs/ 0000775 0000000 0000000 00000000000 14657203754 0017232 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/daemon/daemondefs/daemondefs.go 0000664 0000000 0000000 00000001735 14657203754 0021674 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.21.0/pkg/daemon/internal/ 0000775 0000000 0000000 00000000000 14657203754 0016741 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/daemon/internal/api/ 0000775 0000000 0000000 00000000000 14657203754 0017512 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/daemon/internal/api/api.go 0000664 0000000 0000000 00000003067 14657203754 0020620 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.21.0/pkg/daemon/server.go 0000664 0000000 0000000 00000010504 14657203754 0016762 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.21.0/pkg/daemon/server_test.elvts 0000664 0000000 0000000 00000001071 14657203754 0020550 0 ustar 00root root 0000000 0000000 //each:elvish-in-global
////////////////////
# error conditions #
////////////////////
## no -daemon flag ##
~> elvish
[stderr] internal error: no suitable subprogram
[exit] 2
## superfluous arguments ##
~> elvish -daemon x &check-stderr-contains='arguments are not allowed with -daemon'
[stderr contains "arguments are not allowed with -daemon"] true
[exit] 2
## can't listen to socket ##
//in-temp-dir
~> print > sock
~> elvish -daemon -sock sock -db db &check-stdout-contains='failed to listen on sock'
[stdout contains "failed to listen on sock"] true
[exit] 2
elvish-0.21.0/pkg/daemon/server_test.go 0000664 0000000 0000000 00000007003 14657203754 0020021 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"
"src.elv.sh/pkg/store/storetest"
"src.elv.sh/pkg/testutil"
)
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 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 struct{})
devNull := must.OK1(os.OpenFile(os.DevNull, os.O_RDWR, 0))
go func() {
prog.Run(
[3]*os.File{devNull, devNull, devNull},
args,
&Program{serveOpts: opts})
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()
devNull.Close()
})
return s
}
type server struct {
t *testing.T
ch <-chan struct{}
}
func (s server) WaitQuit() bool {
s.t.Helper()
select {
case <-s.ch:
return true
case <-time.After(testutil.Scaled(2 * time.Second)):
s.t.Error("timed out waiting for daemon to quit")
return 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.21.0/pkg/daemon/server_unix_test.go 0000664 0000000 0000000 00000001173 14657203754 0021066 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.21.0/pkg/daemon/service.go 0000664 0000000 0000000 00000004704 14657203754 0017121 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.21.0/pkg/daemon/sys_unix.go 0000664 0000000 0000000 00000000732 14657203754 0017337 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.21.0/pkg/daemon/sys_windows.go 0000664 0000000 0000000 00000001556 14657203754 0020053 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.21.0/pkg/daemon/transcripts_test.go 0000664 0000000 0000000 00000000526 14657203754 0021072 0 ustar 00root root 0000000 0000000 package daemon_test
import (
"embed"
"testing"
"src.elv.sh/pkg/daemon"
"src.elv.sh/pkg/eval/evaltest"
"src.elv.sh/pkg/prog/progtest"
)
//go:embed *.elvts
var transcripts embed.FS
func TestTranscripts(t *testing.T) {
evaltest.TestTranscriptsInFS(t, transcripts,
"elvish-in-global", progtest.ElvishInGlobal(&daemon.Program{}),
)
}
elvish-0.21.0/pkg/diag/ 0000775 0000000 0000000 00000000000 14657203754 0014566 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/diag/context.go 0000664 0000000 0000000 00000007442 14657203754 0016610 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.21.0/pkg/diag/context_test.go 0000664 0000000 0000000 00000002564 14657203754 0017647 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>
_ compare a b
# ▶ (num -1)
# ~> compare b a
# ▶ (num 1)
# ~> compare x x
# ▶ (num 0)
# ~> compare (num 10) (num 1)
# ▶ (num 1)
# ```
#
# Examples comparing values of different types:
#
# ```elvish-transcript
# //skip-test
# ~> compare a (num 10)
# Exception: bad value: inputs to "compare" or "order" must be comparable values, but is uncomparable values
# [tty]:1:1-18: compare a (num 10)
# ~> compare &total a (num 10)
# ▶ (num -1)
# ~> compare &total (num 10) a
# ▶ (num 1)
# ```
#
# See also [`order`]().
fn compare {|&total=$false a b| }
elvish-0.21.0/pkg/eval/builtin_fn_pred.go 0000664 0000000 0000000 00000002455 14657203754 0020311 0 ustar 00root root 0000000 0000000 package eval
import (
"src.elv.sh/pkg/eval/errs"
"src.elv.sh/pkg/eval/vals"
)
// Basic predicate commands.
func init() {
addBuiltinFns(map[string]any{
"bool": vals.Bool,
"not": not,
"is": is,
"eq": eq,
"not-eq": notEq,
"compare": compare,
})
}
func not(v any) bool {
return !vals.Bool(v)
}
func is(args ...any) bool {
for i := 0; i+1 < len(args); i++ {
if args[i] != args[i+1] {
return false
}
}
return true
}
func eq(args ...any) bool {
for i := 0; i+1 < len(args); i++ {
if !vals.Equal(args[i], args[i+1]) {
return false
}
}
return true
}
func notEq(a, b any) bool {
return !vals.Equal(a, b)
}
// ErrUncomparable is raised by the compare and order commands when inputs contain
// uncomparable values.
var ErrUncomparable = errs.BadValue{
What: `inputs to "compare" or "order"`,
Valid: "comparable values", Actual: "uncomparable values"}
type compareOptions struct {
Total bool
}
func (opts *compareOptions) SetDefaultOptions() {}
func compare(opts compareOptions, a, b any) (int, error) {
var o vals.Ordering
if opts.Total {
o = vals.CmpTotal(a, b)
} else {
o = vals.Cmp(a, b)
}
switch o {
case vals.CmpLess:
return -1, nil
case vals.CmpEqual:
return 0, nil
case vals.CmpMore:
return 1, nil
default:
return 0, ErrUncomparable
}
}
elvish-0.21.0/pkg/eval/builtin_fn_pred_test.elvts 0000664 0000000 0000000 00000006635 14657203754 0022104 0 ustar 00root root 0000000 0000000 ////////
# bool #
////////
~> bool $true
▶ $true
~> bool a
▶ $true
~> bool [a]
▶ $true
// "empty" values are also true in Elvish
~> bool []
▶ $true
~> bool [&]
▶ $true
~> bool (num 0)
▶ $true
~> bool ""
▶ $true
// only errors, $nil and $false are false
~> bool ?(fail x)
▶ $false
~> bool $nil
▶ $false
~> bool $false
▶ $false
///////
# not #
///////
~> not $false
▶ $true
~> not $nil
▶ $true
~> not ?(fail x)
▶ $true
~> not $true
▶ $false
~> not a
▶ $false
//////
# is #
//////
// The semantics of "is" is not well-defined, so these results might change in
// future.
~> is 1 1
▶ $true
~> is a b
▶ $false
~> is [] []
▶ $true
~> is [1] [1]
▶ $false
//////
# eq #
//////
~> eq 1 1
▶ $true
~> eq a b
▶ $false
~> eq [] []
▶ $true
~> eq [1] [1]
▶ $true
~> eq 1 1 2
▶ $false
//////////
# not-eq #
//////////
~> not-eq a b
▶ $true
~> not-eq a a
▶ $false
// not-eq only accepts two arguments
~> not-eq
Exception: arity mismatch: arguments must be 2 values, but is 0 values
[tty]:1:1-6: not-eq
~> not-eq 1
Exception: arity mismatch: arguments must be 2 values, but is 1 value
[tty]:1:1-8: not-eq 1
~> not-eq 1 2 1
Exception: arity mismatch: arguments must be 2 values, but is 3 values
[tty]:1:1-12: not-eq 1 2 1
///////////
# compare #
///////////
## strings ##
~> compare a b
▶ (num -1)
~> compare b a
▶ (num 1)
~> compare x x
▶ (num 0)
## numbers ##
~> compare (num 1) (num 2)
▶ (num -1)
~> compare (num 2) (num 1)
▶ (num 1)
~> compare (num 3) (num 3)
▶ (num 0)
~> compare (num 1/4) (num 1/2)
▶ (num -1)
~> compare (num 1/3) (num 0.2)
▶ (num 1)
~> compare (num 3.0) (num 3)
▶ (num 0)
~> compare (num nan) (num 3)
▶ (num -1)
~> compare (num 3) (num nan)
▶ (num 1)
~> compare (num nan) (num nan)
▶ (num 0)
## booleans ##
~> compare $true $false
▶ (num 1)
~> compare $false $true
▶ (num -1)
~> compare $false $false
▶ (num 0)
~> compare $true $true
▶ (num 0)
## lists ##
~> compare [a, b] [a, a]
▶ (num 1)
~> compare [a, a] [a, b]
▶ (num -1)
~> compare [x, y] [x, y]
▶ (num 0)
## different types are uncomparable without &total. ##
~> compare 1 (num 1)
Exception: bad value: inputs to "compare" or "order" must be comparable values, but is uncomparable values
[tty]:1:1-17: compare 1 (num 1)
~> compare x [x]
Exception: bad value: inputs to "compare" or "order" must be comparable values, but is uncomparable values
[tty]:1:1-13: compare x [x]
~> compare a [&a=x]
Exception: bad value: inputs to "compare" or "order" must be comparable values, but is uncomparable values
[tty]:1:1-16: compare a [&a=x]
## uncomparable types ##
~> compare { nop 1 } { nop 2}
Exception: bad value: inputs to "compare" or "order" must be comparable values, but is uncomparable values
[tty]:1:1-26: compare { nop 1 } { nop 2}
~> compare [&foo=bar] [&a=b]
Exception: bad value: inputs to "compare" or "order" must be comparable values, but is uncomparable values
[tty]:1:1-25: compare [&foo=bar] [&a=b]
## total ordering for the same comparable type ##
~> compare &total (num 1) (num 3/2)
▶ (num -1)
~> compare &total (num 3/2) (num 2)
▶ (num -1)
## total ordering for the same uncomparable type ##
~> compare &total { nop 1 } { nop 2 }
▶ (num 0)
~> compare &total [&foo=bar] [&a=b]
▶ (num 0)
## total ordering for different types ##
~> == (compare &total foo (num 2)) (compare &total bar (num 10))
▶ $true
~> + (compare &total foo (num 2)) (compare &total (num 2) foo)
▶ (num 0)
elvish-0.21.0/pkg/eval/builtin_fn_str.d.elv 0000664 0000000 0000000 00000003227 14657203754 0020570 0 ustar 00root root 0000000 0000000 #doc:html-id str-lt
# Outputs whether `$string`s in the given order are strictly increasing. Outputs
# `$true` when given fewer than two strings.
fn 's' {|@string| }
#doc:html-id str-ge
# Outputs whether `$string`s in the given order are strictly non-increasing.
# Outputs `$true` when given fewer than two strings.
fn '>=s' {|@string| }
# Output the width of `$string` when displayed on the terminal. Examples:
#
# ```elvish-transcript
# ~> wcswidth a
# ▶ (num 1)
# ~> wcswidth lorem
# ▶ (num 5)
# ~> wcswidth 你好,世界
# ▶ (num 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| }
elvish-0.21.0/pkg/eval/builtin_fn_str.go 0000664 0000000 0000000 00000004255 14657203754 0020167 0 ustar 00root root 0000000 0000000 package eval
import (
"fmt"
"math"
"math/big"
"strconv"
"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": chainStringComparer(func(a, b string) bool { return a > b }),
">=s": chainStringComparer(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,
})
}
func chainStringComparer(p func(a, b string) bool) func(...string) bool {
return func(s ...string) bool {
for i := 0; i < len(s)-1; i++ {
if !p(s[i], s[i+1]) {
return false
}
}
return true
}
}
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
}
elvish-0.21.0/pkg/eval/builtin_fn_str_test.elvts 0000664 0000000 0000000 00000003775 14657203754 0021764 0 ustar 00root root 0000000 0000000 /////////////////////
# string comparison #
/////////////////////
~> <=s a a
▶ $true
~> <=s a b
▶ $true
~> <=s b a
▶ $false
~> <=s a a b
▶ $true
~> ==s haha haha
▶ $true
~> ==s 10 10.0
▶ $false
~> ==s a a a
▶ $true
~> >s a b
▶ $false
~> >s 2 10
▶ $true
~> >s c b a
▶ $true
~> >=s a a
▶ $true
~> >=s a b
▶ $false
~> >=s b a
▶ $true
~> >=s b a a
▶ $true
~> !=s haha haha
▶ $false
~> !=s 10 10.1
▶ $true
// !=s only accepts two arguments
~> !=s a b a
Exception: arity mismatch: arguments must be 2 values, but is 3 values
[tty]:1:1-9: !=s a b a
/////////////
# 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)
elvish-0.21.0/pkg/eval/builtin_fn_stream.d.elv 0000664 0000000 0000000 00000017763 14657203754 0021265 0 ustar 00root root 0000000 0000000 # Takes [value inputs](#value-inputs), and outputs those values unchanged.
#
# This is an [identity
# function](https://en.wikipedia.org/wiki/Identity_function) for the value
# channel; in other words, `a | all` is equivalent to just `a` if `a` only has
# value output.
#
# This command can be used inside output capture (i.e. `(all)`) to turn value
# inputs into arguments. For example:
#
# ```elvish-transcript
# ~> echo '["foo","bar"] ["lorem","ipsum"]' | from-json
# ▶ [foo bar]
# ▶ [lorem ipsum]
# ~> echo '["foo","bar"] ["lorem","ipsum"]' | from-json | put (all)[0]
# ▶ foo
# ▶ lorem
# ```
#
# The latter pipeline is equivalent to the following:
#
# ```elvish-transcript
# ~> put (echo '["foo","bar"] ["lorem","ipsum"]' | from-json)[0]
# ▶ foo
# ▶ lorem
# ```
#
# In general, when `(all)` appears in the last command of a pipeline, it is
# equivalent to just moving the previous commands of the pipeline into `()`.
# The choice is a stylistic one; the `(all)` variant is longer overall, but can
# be more readable since it allows you to avoid putting an excessively long
# pipeline inside an output capture, and keeps the data flow within the
# pipeline.
#
# Putting the value capture inside `[]` (i.e. `[(all)]`) is useful for storing
# all value inputs in a list for further processing:
#
# ```elvish-transcript
# ~> fn f { var inputs = [(all)]; put $inputs[1] }
# ~> put foo bar baz | f
# ▶ bar
# ```
#
# The `all` command can also take "inputs" from an iterable argument. This can
# be used to flatten lists or strings (although not recursively):
#
# ```elvish-transcript
# ~> all [foo [lorem ipsum]]
# ▶ foo
# ▶ [lorem ipsum]
# ~> all foo
# ▶ f
# ▶ o
# ▶ o
# ```
#
# This can be used together with `(one)` to turn a single iterable value in the
# pipeline into its elements:
#
# ```elvish-transcript
# ~> echo '["foo","bar","lorem"]' | from-json
# ▶ [foo bar lorem]
# ~> echo '["foo","bar","lorem"]' | from-json | all (one)
# ▶ foo
# ▶ bar
# ▶ lorem
# ```
#
# When given byte inputs, the `all` command currently functions like
# [`from-lines`](), although this behavior is subject to change:
#
# ```elvish-transcript
# ~> print "foo\nbar\n" | all
# ▶ foo
# ▶ bar
# ```
#
# See also [`one`]().
fn all {|inputs?| }
# Takes exactly one [value input](#value-inputs) and outputs it. If there are
# more than one value inputs, raises an exception.
#
# This function can be used in a similar way to [`all`](), but is a better
# choice when you expect that there is exactly one output.
#
# See also [`all`]().
fn one {|inputs?| }
# Outputs the first `$n` [value inputs](#value-inputs). If `$n` is larger than
# the number of value inputs, outputs everything.
#
# Examples:
#
# ```elvish-transcript
# ~> range 2 | take 10
# ▶ (num 0)
# ▶ (num 1)
# ~> take 3 [a b c d e]
# ▶ a
# ▶ b
# ▶ c
# ~> use str
# ~> str:split ' ' 'how are you?' | take 1
# ▶ how
# ```
#
# Etymology: Haskell.
#
# See also [`drop`]().
fn take {|n inputs?| }
# Ignores the first `$n` [value inputs](#value-inputs) and outputs the rest.
# If `$n` is larger than the number of value inputs, outputs nothing.
#
# Example:
#
# ```elvish-transcript
# ~> range 10 | drop 8
# ▶ (num 8)
# ▶ (num 9)
# ~> range 2 | drop 10
# ~> drop 2 [a b c d e]
# ▶ c
# ▶ d
# ▶ e
# ~> use str
# ~> str:split ' ' 'how are you?' | drop 1
# ▶ are
# ▶ 'you?'
# ```
#
# Etymology: Haskell.
#
# See also [`take`]().
fn drop {|n inputs?| }
# Replaces consecutive runs of equal values with a single copy. Similar to the
# `uniq` command on Unix.
#
# Examples:
#
# ```elvish-transcript
# ~> put a a b b c | compact
# ▶ a
# ▶ b
# ▶ c
# ~> compact [a a b b c]
# ▶ a
# ▶ b
# ▶ c
# ~> put a b a | compact
# ▶ a
# ▶ b
# ▶ a
# ```
fn compact {|inputs?| }
# Count the number of elements in a container (also known as its _length_), or
# the number of inputs when when argument is given.
#
# Examples:
#
# ```elvish-transcript
# ~> count [lorem ipsum]
# ▶ (num 2)
# ~> count [&foo=bar &lorem=ipsum] # count pairs in a map
# ▶ (num 2)
# ~> count lorem # count bytes in a string
# ▶ (num 5)
# ~> range 100 | count # count inputs
# ▶ (num 100)
# ~> seq 100 | count
# ▶ (num 100)
# ```
fn count {|inputs?| }
# Outputs the [value inputs](#value-inputs) after sorting. The sorting process
# is
# [stable](https://en.wikipedia.org/wiki/Sorting_algorithm#Stability).
#
# By default, `order` sorts the values in ascending order, using the same
# comparator as [`compare`](), which only supports values of the same ordered
# type. Its options modify this behavior:
#
# - The `&less-than` option, if given, overrides the comparator. Its
# value should be a function that takes two arguments `$a` and `$b` and
# outputs a boolean indicating whether `$a` is less than `$b`. If the
# function throws an exception, `order` rethrows the exception without
# outputting any value.
#
# The default behavior of `order` is equivalent to `order &less-than={|a b|
# == -1 (compare $a $b)}`.
#
# - The `&total` option, if true, overrides the comparator to be same as
# `compare &total=$true`, which allows sorting values of mixed types and
# unordered types. The result groups values by their types. Groups of
# ordered types are sorted internally, and groups of unordered types retain
# their original relative order.
#
# Specifying `&total=$true` is equivalent to specifying `&less-than={|a b|
# == -1 (compare &total=$true $a $b)}`. It is an error to both specify
# `&total=$true` and a non-nil `&less-than` callback.
#
# - The `&key` option, if given, is a function that gets called with each
# input value. It must output a single value, which is used for comparison
# in place of the original value. The comparator used can be affected by
# `$less-than` or `&total`.
#
# If the function throws an exception, `order` rethrows the exception.
#
# Use of `&key` can usually be rewritten to use `&less-than` instead, but
# using `&key` can be faster. The `&key` callback is only called once for
# each element, whereas the `&less-than` callback is called O(n*lg(n)) times
# on average.
#
# - The `&reverse` option, if true, reverses the order of output.
#
# Examples:
#
# ```elvish-transcript
# ~> put foo bar ipsum | order
# ▶ bar
# ▶ foo
# ▶ ipsum
# ~> order [(num 10) (num 1) (num 5)]
# ▶ (num 1)
# ▶ (num 5)
# ▶ (num 10)
# ~> order [[a b] [a] [b b] [a c]]
# ▶ [a]
# ▶ [a b]
# ▶ [a c]
# ▶ [b b]
# ~> order &reverse [a c b]
# ▶ c
# ▶ b
# ▶ a
# ~> put [0 x] [1 a] [2 b] | order &key={|l| put $l[1]}
# ▶ [1 a]
# ▶ [2 b]
# ▶ [0 x]
# ~> order &less-than={|a b| eq $a x } [l x o r x e x m]
# ▶ x
# ▶ x
# ▶ x
# ▶ l
# ▶ o
# ▶ r
# ▶ e
# ▶ m
# ```
#
# Ordering heterogeneous values:
#
# ```elvish-transcript
# //skip-test
# ~> order [a (num 2) c (num 0) b (num 1)]
# Exception: bad value: inputs to "compare" or "order" must be comparable values, but is uncomparable values
# [tty]:1:1-37: order [a (num 2) c (num 0) b (num 1)]
# ~> order &total [a (num 2) c (num 0) b (num 1)]
# ▶ (num 0)
# ▶ (num 1)
# ▶ (num 2)
# ▶ a
# ▶ b
# ▶ c
# ```
#
# Beware that strings that look like numbers are treated as strings, not
# numbers. To sort strings as numbers, use an explicit `&key` or `&less-than`:
#
# ```elvish-transcript
# ~> order [5 1 10]
# ▶ 1
# ▶ 10
# ▶ 5
# ~> order &key=$num~ [5 1 10]
# ▶ 1
# ▶ 5
# ▶ 10
# ~> order &less-than=$"<~" [5 1 10]
# ▶ 1
# ▶ 5
# ▶ 10
# ```
#
# (The `$"<~"` syntax is a reference to [the `<` function](#num-lt).)
#
# See also [`compare`]().
fn order {|&less-than=$nil &total=$false &key=$nil &reverse=$false inputs?| }
#doc:added-in 0.21
# Calls `$predicate` for each input, outputting those where `$predicate` outputs
# `$true`. Similar to `filter` in some other languages.
#
# The `$predicate` must output a single boolean value.
#
# Examples:
#
# ```elvish-transcript
# ~> use str
# ~> put foo bar foobar | keep-if {|s| str:has-prefix $s f }
# ▶ foo
# ▶ foobar
# ~> keep-if {|s| == 3 (count $s) } [foo bar foobar]
# ▶ foo
# ▶ bar
# ```
fn keep-if {|predicate inputs?| }
elvish-0.21.0/pkg/eval/builtin_fn_stream.go 0000664 0000000 0000000 00000013250 14657203754 0020645 0 ustar 00root root 0000000 0000000 package eval
import (
"errors"
"fmt"
"sort"
"src.elv.sh/pkg/eval/errs"
"src.elv.sh/pkg/eval/vals"
)
// Stream manipulation.
func init() {
addBuiltinFns(map[string]any{
"all": all,
"one": one,
"take": take,
"drop": drop,
"compact": compact,
"count": count,
"order": order,
// Iterations
"keep-if": keepIf,
})
}
func all(fm *Frame, inputs Inputs) error {
out := fm.ValueOutput()
var errOut error
inputs(func(v any) {
if errOut != nil {
return
}
errOut = out.Put(v)
})
return errOut
}
func one(fm *Frame, inputs Inputs) error {
var val any
n := 0
inputs(func(v any) {
if n == 0 {
val = v
}
n++
})
if n == 1 {
return fm.ValueOutput().Put(val)
}
return errs.ArityMismatch{What: "values", ValidLow: 1, ValidHigh: 1, Actual: n}
}
func take(fm *Frame, n int, inputs Inputs) error {
out := fm.ValueOutput()
var errOut error
i := 0
inputs(func(v any) {
if errOut != nil {
return
}
if i < n {
errOut = out.Put(v)
}
i++
})
return errOut
}
func drop(fm *Frame, n int, inputs Inputs) error {
out := fm.ValueOutput()
var errOut error
i := 0
inputs(func(v any) {
if errOut != nil {
return
}
if i >= n {
errOut = out.Put(v)
}
i++
})
return errOut
}
func compact(fm *Frame, inputs Inputs) error {
out := fm.ValueOutput()
first := true
var errOut error
var prev any
inputs(func(v any) {
if errOut != nil {
return
}
if first || !vals.Equal(v, prev) {
errOut = out.Put(v)
first = false
prev = v
}
})
return errOut
}
// The count implementation uses a custom varargs based implementation rather
// than the more common `Inputs` API (see pkg/eval/go_fn.go) because this
// allows the implementation to be O(1) for the common cases rather than O(n).
func count(fm *Frame, args ...any) (int, error) {
var n int
switch nargs := len(args); nargs {
case 0:
// Count inputs.
fm.IterateInputs(func(any) {
n++
})
case 1:
// Get length of argument.
v := args[0]
if len := vals.Len(v); len >= 0 {
n = len
} else {
err := vals.Iterate(v, func(any) bool {
n++
return true
})
if err != nil {
return 0, fmt.Errorf("cannot get length of a %s", vals.Kind(v))
}
}
default:
// The error matches what would be returned if the `Inputs` API was
// used. See GoFn.Call().
return 0, errs.ArityMismatch{What: "arguments", ValidLow: 0, ValidHigh: 1, Actual: nargs}
}
return n, nil
}
type orderOptions struct {
Reverse bool
Key Callable
Total bool
LessThan Callable
}
func (opt *orderOptions) SetDefaultOptions() {}
// ErrBothTotalAndLessThan is returned by order when both the &total and
// &less-than options are specified.
var ErrBothTotalAndLessThan = errors.New("both &total and &less-than specified")
func order(fm *Frame, opts orderOptions, inputs Inputs) error {
if opts.Total && opts.LessThan != nil {
return ErrBothTotalAndLessThan
}
var values, keys []any
inputs(func(v any) { values = append(values, v) })
if opts.Key != nil {
keys = make([]any, len(values))
for i, value := range values {
outputs, err := fm.CaptureOutput(func(fm *Frame) error {
return opts.Key.Call(fm, []any{value}, NoOpts)
})
if err != nil {
return err
} else if len(outputs) != 1 {
return errs.ArityMismatch{
What: "number of outputs of the &key callback",
ValidLow: 1, ValidHigh: 1, Actual: len(outputs)}
}
keys[i] = outputs[0]
}
}
s := &slice{fm, opts.Total, opts.LessThan, values, keys, nil}
if opts.Reverse {
sort.Stable(sort.Reverse(s))
} else {
sort.Stable(s)
}
if s.err != nil {
return s.err
}
out := fm.ValueOutput()
for _, v := range values {
err := out.Put(v)
if err != nil {
return err
}
}
return nil
}
type slice struct {
fm *Frame
total bool
lessThan Callable
values []any
keys []any // nil if no keys
err error
}
func (s *slice) Len() int { return len(s.values) }
func (s *slice) Less(i, j int) bool {
if s.err != nil {
return true
}
var a, b any
if s.keys != nil {
a, b = s.keys[i], s.keys[j]
} else {
a, b = s.values[i], s.values[j]
}
if s.lessThan == nil {
// Use a builtin comparator depending on s.mixed.
if s.total {
return vals.CmpTotal(a, b) == vals.CmpLess
}
o := vals.Cmp(a, b)
if o == vals.CmpUncomparable {
s.err = ErrUncomparable
return true
}
return o == vals.CmpLess
}
// Use the &less-than callback.
outputs, err := s.fm.CaptureOutput(func(fm *Frame) error {
return s.lessThan.Call(fm, []any{a, b}, NoOpts)
})
if err != nil {
s.err = err
return true
}
if len(outputs) != 1 {
s.err = errs.ArityMismatch{
What: "number of outputs of the &less-than callback",
ValidLow: 1, ValidHigh: 1, Actual: len(outputs)}
return true
}
if b, ok := outputs[0].(bool); ok {
return b
}
s.err = errs.BadValue{
What: "output of the &less-than callback",
Valid: "boolean", Actual: vals.Kind(outputs[0])}
return true
}
func (s *slice) Swap(i, j int) {
s.values[i], s.values[j] = s.values[j], s.values[i]
if s.keys != nil {
s.keys[i], s.keys[j] = s.keys[j], s.keys[i]
}
}
func keepIf(fm *Frame, f Callable, inputs Inputs) error {
var err error
inputs(func(v any) {
if err != nil {
return
}
outputs, errF := fm.CaptureOutput(func(fm *Frame) error {
return f.Call(fm, []any{v}, NoOpts)
})
if errF != nil {
err = errF
} else if len(outputs) != 1 {
err = errs.ArityMismatch{
What: "number of callback outputs",
ValidLow: 1, ValidHigh: 1, Actual: len(outputs),
}
} else {
b, ok := outputs[0].(bool)
if !ok {
err = errs.BadValue{What: "callback output",
Valid: "bool", Actual: vals.ReprPlain(outputs[0])}
} else if b {
err = fm.ValueOutput().Put(v)
}
}
})
return err
}
elvish-0.21.0/pkg/eval/builtin_fn_stream_test.elvts 0000664 0000000 0000000 00000017216 14657203754 0022442 0 ustar 00root root 0000000 0000000 ///////
# all #
///////
~> put foo bar | all
▶ foo
▶ bar
~> echo foobar | all
▶ foobar
~> all [foo bar]
▶ foo
▶ bar
// bubbling output errors
~> all [foo bar] >&-
Exception: port does not support value output
[tty]:1:1-17: all [foo bar] >&-
///////
# one #
///////
~> put foo | one
▶ foo
~> put | one
Exception: arity mismatch: values must be 1 value, but is 0 values
[tty]:1:7-9: put | one
~> put foo bar | one
Exception: arity mismatch: values must be 1 value, but is 2 values
[tty]:1:15-17: put foo bar | one
~> one [foo]
▶ foo
~> one []
Exception: arity mismatch: values must be 1 value, but is 0 values
[tty]:1:1-6: one []
~> one [foo bar]
Exception: arity mismatch: values must be 1 value, but is 2 values
[tty]:1:1-13: one [foo bar]
// bubbling output errors
~> one [foo] >&-
Exception: port does not support value output
[tty]:1:1-13: one [foo] >&-
////////
# take #
////////
~> range 100 | take 2
▶ (num 0)
▶ (num 1)
// bubbling output errors
~> take 1 [foo bar] >&-
Exception: port does not support value output
[tty]:1:1-20: take 1 [foo bar] >&-
////////
# drop #
////////
~> range 100 | drop 98
▶ (num 98)
▶ (num 99)
// bubbling output errors
~> drop 1 [foo bar lorem] >&-
Exception: port does not support value output
[tty]:1:1-26: drop 1 [foo bar lorem] >&-
///////////
# compact #
///////////
~> put a a b b c | compact
▶ a
▶ b
▶ c
~> put a b a | compact
▶ a
▶ b
▶ a
// bubbling output errors
~> compact [a a] >&-
Exception: port does not support value output
[tty]:1:1-17: compact [a a] >&-
/////////
# count #
/////////
~> range 100 | count
▶ (num 100)
~> count [(range 100)]
▶ (num 100)
~> count 123
▶ (num 3)
~> count 1 2 3
Exception: arity mismatch: arguments must be 0 to 1 values, but is 3 values
[tty]:1:1-11: count 1 2 3
~> count $true
Exception: cannot get length of a bool
[tty]:1:1-11: count $true
/////////
# order #
/////////
## strings ##
~> put foo bar ipsum | order
▶ bar
▶ foo
▶ ipsum
~> put foo bar bar | order
▶ bar
▶ bar
▶ foo
~> put 10 1 5 2 | order
▶ 1
▶ 10
▶ 2
▶ 5
## booleans ##
~> put $true $false $true | order
▶ $false
▶ $true
▶ $true
~> put $false $true $false | order
▶ $false
▶ $false
▶ $true
## typed numbers ##
// int
~> put 10 1 1 | each $num~ | order
▶ (num 1)
▶ (num 1)
▶ (num 10)
~> put 10 1 5 2 -1 | each $num~ | order
▶ (num -1)
▶ (num 1)
▶ (num 2)
▶ (num 5)
▶ (num 10)
// int and *big.Int
~> put 1 100000000000000000000 2 100000000000000000000 | each $num~ | order
▶ (num 1)
▶ (num 2)
▶ (num 100000000000000000000)
▶ (num 100000000000000000000)
// int and *big.Rat
~> put 1 2 3/2 3/2 | each $num~ | order
▶ (num 1)
▶ (num 3/2)
▶ (num 3/2)
▶ (num 2)
// int and float64
~> put 1 1.5 2 1.5 | each $num~ | order
▶ (num 1)
▶ (num 1.5)
▶ (num 1.5)
▶ (num 2)
// NaN's are considered smaller than other numbers for ordering
~> put NaN -1 NaN | each $num~ | order
▶ (num NaN)
▶ (num NaN)
▶ (num -1)
## lists ##
~> put [b] [a] | order
▶ [a]
▶ [b]
~> put [a] [b] [a] | order
▶ [a]
▶ [a]
▶ [b]
~> put [(num 10)] [(num 2)] | order
▶ [(num 2)]
▶ [(num 10)]
~> put [a b] [b b] [a c] | order
▶ [a b]
▶ [a c]
▶ [b b]
~> put [a] [] [a (num 2)] [a (num 1)] | order
▶ []
▶ [a]
▶ [a (num 1)]
▶ [a (num 2)]
## &reverse ##
~> put foo bar ipsum | order &reverse
▶ ipsum
▶ foo
▶ bar
## &key ##
~> put 10 1 5 2 | order &key={|v| num $v }
▶ 1
▶ 2
▶ 5
▶ 10
## &key and &reverse ##
~> put 10 1 5 2 | order &reverse &key={|v| num $v }
▶ 10
▶ 5
▶ 2
▶ 1
## different types without &total ##
~> put (num 1) 1 | order
Exception: bad value: inputs to "compare" or "order" must be comparable values, but is uncomparable values
[tty]:1:17-21: put (num 1) 1 | order
~> put 1 (num 1) | order
Exception: bad value: inputs to "compare" or "order" must be comparable values, but is uncomparable values
[tty]:1:17-21: put 1 (num 1) | order
~> put 1 (num 1) b | order
Exception: bad value: inputs to "compare" or "order" must be comparable values, but is uncomparable values
[tty]:1:19-23: put 1 (num 1) b | order
~> put [a] a | order
Exception: bad value: inputs to "compare" or "order" must be comparable values, but is uncomparable values
[tty]:1:13-17: put [a] a | order
~> put [a] [(num 1)] | order
Exception: bad value: inputs to "compare" or "order" must be comparable values, but is uncomparable values
[tty]:1:21-25: put [a] [(num 1)] | order
## different types with &total ##
// &total orders the values into groups of different types, and sorts
// the groups themselves. Test that without assuming the relative order
// between numbers and strings.
~> put (num 3/2) (num 1) c (num 2) a | order &total | var li = [(all)]
or (eq $li [a c (num 1) (num 3/2) (num 2)]) ^
(eq $li [(num 1) (num 3/2) (num 2) a c])
▶ $true
// &total keeps the order of unordered values as is.
~> put [&foo=bar] [&a=b] [&x=y] | order &total
▶ [&foo=bar]
▶ [&a=b]
▶ [&x=y]
## &less-than ##
~> put 1 10 2 5 | order &less-than={|a b| < $a $b }
▶ 1
▶ 2
▶ 5
▶ 10
## &less-than and &key ##
~> put [a 1] [b 10] [c 2] | order &key={|v| put $v[1]} &less-than=$'<~'
▶ [a 1]
▶ [c 2]
▶ [b 10]
## &less-than and &reverse ##
~> put 1 10 2 5 | order &reverse &less-than={|a b| < $a $b }
▶ 10
▶ 5
▶ 2
▶ 1
## &less-than writing more than one value ##
~> put 1 10 2 5 | order &less-than={|a b| put $true $false }
Exception: arity mismatch: number of outputs of the &less-than callback must be 1 value, but is 2 values
[tty]:1:16-57: put 1 10 2 5 | order &less-than={|a b| put $true $false }
## &less-than writing non-boolean value ##
~> put 1 10 2 5 | order &less-than={|a b| put x }
Exception: bad value: output of the &less-than callback must be boolean, but is string
[tty]:1:16-46: put 1 10 2 5 | order &less-than={|a b| put x }
## &less-than throwing an exception ##
~> put 1 10 2 5 | order &less-than={|a b| fail bad }
Exception: bad
[tty]:1:40-48: put 1 10 2 5 | order &less-than={|a b| fail bad }
[tty]:1:16-49: put 1 10 2 5 | order &less-than={|a b| fail bad }
## all callback options support $nil for default behavior ##
~> put c b a | order &less-than=$nil &key=$nil
▶ a
▶ b
▶ c
## sort is stable ##
// Test stability by pretending that all values but one are equal, and check
// that the order among them has not changed.
~> put l x o x r x e x m | order &less-than={|a b| eq $a x }
▶ x
▶ x
▶ x
▶ x
▶ l
▶ o
▶ r
▶ e
▶ m
## &total and &less-than are mutually exclusive ##
~> put x | order &total &less-than={|a b| put $true }
Exception: both &total and &less-than specified
[tty]:1:9-50: put x | order &total &less-than={|a b| put $true }
## bubbling output errors ##
~> order [foo] >&-
Exception: port does not support value output
[tty]:1:1-15: order [foo] >&-
///////////
# keep-if #
///////////
~> use str
~> put foo bar foobar | keep-if {|s| str:has-prefix $s f}
▶ foo
▶ foobar
## wrong number of outputs ##
~> put foo bar foobar | keep-if {|_| }
Exception: arity mismatch: number of callback outputs must be 1 value, but is 0 values
[tty]:1:22-35: put foo bar foobar | keep-if {|_| }
~> put foo bar foobar | keep-if {|_| put $true $false }
Exception: arity mismatch: number of callback outputs must be 1 value, but is 2 values
[tty]:1:22-52: put foo bar foobar | keep-if {|_| put $true $false }
## wrong type of output ##
~> put foo bar foobar | keep-if {|_| put foo }
Exception: bad value: callback output must be bool, but is foo
[tty]:1:22-43: put foo bar foobar | keep-if {|_| put foo }
## callback throws exception ##
~> put foo bar foobar | keep-if {|_| fail bad}
Exception: bad
[tty]:1:35-42: put foo bar foobar | keep-if {|_| fail bad}
[tty]:1:22-43: put foo bar foobar | keep-if {|_| fail bad}
elvish-0.21.0/pkg/eval/builtin_fn_styled.d.elv 0000664 0000000 0000000 00000011771 14657203754 0021267 0 ustar 00root root 0000000 0000000 # Constructs a styled segment, a building block for styled texts.
#
# - If `$object` is a string, constructs a styled segment with `$object` as the
# content, and the properties specified by the options.
#
# - If `$object` is a styled segment, constructs a styled segment that is a
# copy of `$object`, with the properties specified by the options overridden.
#
# The properties of styled segments can be inspected by indexing into it. Valid
# keys are the same as the options to `styled-segment`, plus `text` for the
# string content:
#
# ```elvish-transcript
# ~> var s = (styled-segment abc &bold)
# ~> put $s[text]
# ▶ abc
# ~> put $s[fg-color]
# ▶ default
# ~> put $s[bold]
# ▶ $true
# ```
#
# Prefer the high-level [`styled`]() command to build and transform styled
# texts. Styled segments are a low-level construct, and you only have to deal
# with it when building custom style transformers.
#
# In the following example, a custom transformer sets the `inverse` property
# for every bold segment:
#
# ```elvish
# styled foo(styled bar bold) {|x| styled-segment $x &inverse=$x[bold] }
# # transforms "foo" + bold "bar" into "foo" + bold and inverse "bar"
# ```
fn styled-segment {|object &fg-color=default &bg-color=default &bold=$false &dim=$false &italic=$false &underlined=$false &blink=$false &inverse=$false| }
# Constructs a **styled text** by applying the supplied transformers to the
# supplied `$object`, which may be a string, a [styled
# segment](#styled-segment), or an existing styled text.
#
# Each `$style-transformer` can be one of the following:
#
# - A boolean attribute name:
#
# - One of `bold`, `dim`, `italic`, `underlined`, `blink` and `inverse` for
# setting the corresponding attribute.
#
# - An attribute name prefixed by `no-` for unsetting the attribute.
#
# - An attribute name prefixed by `toggle-` for toggling the attribute
# between set and unset.
#
# - A color name for setting the text color, which may be one of the
# following:
#
# - One of the 8 basic ANSI colors: `black`, `red`, `green`, `yellow`,
# `blue`, `magenta`, `cyan` and `white`.
#
# - The bright variant of the 8 basic ANSI colors, with a `bright-` prefix.
#
# - Any color from the xterm 256-color palette, as `colorX` (such as
# `color12`).
#
# - A 24-bit RGB color written as `#RRGGBB` (such as `'#778899'`).
#
# **Note**: You need to quote such values, since an unquoted `#` introduces
# a comment (e.g. use `'bg-#778899'` instead of `bg-#778899`).
#
# - A color name prefixed by `fg-` to set the foreground color. This has
# the same effect as specifying the color name without the `fg-` prefix.
#
# - A color name prefixed by `bg-` to set the background color.
#
# - A function that receives a styled segment as the only argument and outputs
# a single styled segment: this function will be applied to all the segments.
#
# When a styled text is converted to a string the corresponding
# [ANSI SGR code](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_.28Select_Graphic_Rendition.29_parameters)
# is built to render the style.
#
# If the [`NO_COLOR`](https://no-color.org) environment variable is set and
# non-empty when Elvish starts, color output is suppressed. Modifications to
# `NO_COLOR` within Elvish (including from `rc.elv`) do not affect the current
# process, but will affect child Elvish processes.
#
# Examples:
#
# ```elvish
# echo (styled foo red bold) # prints red bold "foo"
# echo (styled (styled foo red bold) green) # prints green bold "foo"
# ```
#
# A styled text can contain multiple [segments](#styled-segment) with different
# styles. Such styled texts can be constructed by concatenating multiple styled
# texts with the [compounding](language.html#compounding) syntax. Strings and
# styled segments are automatically "promoted" to styled texts when
# concatenating. Examples:
#
# ```elvish
# echo foo(styled bar red) # prints "foo" + red "bar"
# echo (styled foo bold)(styled bar red) # prints bold "foo" + red "bar"
# ```
#
# The individual segments in a styled text can be extracted by indexing:
#
# ```elvish
# var s = (styled abc red)(styled def green)
# put $s[0] $s[1]
# ```
#
# When printed to the terminal, a styled text is not affected by any existing
# SGR styles in effect, and it will always reset the SGR style afterwards. For
# example:
#
# ```elvish
# print "\e[1m"
# echo (styled foo red)
# echo bar
# # "foo" will be printed as red, but not bold
# # "bar" will be printed without any style
# ```
#
# See also [`render-styledown`]().
fn styled {|object @style-transformer| }
#doc:added-in 0.21
# Renders "styledown" markup into a styled text. For the styledown markup
# format, see
")
} 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.21.0/pkg/md/fmt_test.go 0000664 0000000 0000000 00000016521 14657203754 0016443 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.21.0/pkg/md/html.go 0000664 0000000 0000000 00000010104 14657203754 0015551 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.21.0/pkg/md/inline.go 0000664 0000000 0000000 00000046113 14657203754 0016074 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.31.2/#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.31.2/#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.31.2/#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.31.2/#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.31.2/#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.31.2/#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.31.2/#backslash-escapes
if p.pos < len(p.text) {
if p.text[p.pos] == '\n' {
// https://spec.commonmark.org/0.31.2/#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.31.2/#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.31.2/#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.31.2/#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.31.2/#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.31.2/#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.31.2/#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.31.2/#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's definition of Unicode punctuation includes both P and S
// categories: https://spec.commonmark.org/0.31.2/#unicode-punctuation-character
func isUnicodePunct(r rune) bool {
return unicode.IsPunct(r) || unicode.IsSymbol(r)
}
const metas = "![]*_`\\&<\n"
func isMeta(b byte) bool { return strings.IndexByte(metas, b) >= 0 }
elvish-0.21.0/pkg/md/md.go 0000664 0000000 0000000 00000103035 14657203754 0015213 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].
//
// The package also supports the following extensions:
//
// - ATX headers may be followed by [Pandoc header attributes] {...}.
//
// These omitted features are never used in Elvish's Markdown sources.
//
// All implemented features pass their relevant CommonMark spec tests, currently
// targeting [CommonMark 0.31.2]. See [testutils_test.go] for a complete list of
// which spec tests are skipped.
//
// # 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.
//
// [Pandoc header attributes]: https://pandoc.org/MANUAL.html#extension-header_attributes
// [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/
// [loose]: https://spec.commonmark.org/0.31.2/#loose
// [Setext headings]: https://spec.commonmark.org/0.31.2/#setext-headings
// [ATX headings]: https://spec.commonmark.org/0.31.2/#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.31.2/#inline-link
// [Reference links]: https://spec.commonmark.org/0.31.2/#reference-link
// [CommonMark 0.31.2]: https://spec.commonmark.org/0.31.2/
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.31.2/#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
// 1-based line number. If the Op spans multiple lines, this identifies the
// first line. For the *End types, this identifies the first line that
// causes the block to be terminated, which can be the first line of another
// block.
LineNo int
// For OpOrderedListStart (the start number) or OpHeading (as the heading
// level)
Number int
// For OpHeading (attributes inside { }) and OpCodeBlock (text after opening
// fence)
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, 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.31.2/#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.31.2/#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]*$")
// These corresponds to the bullet list in
// https://spec.commonmark.org/0.31.2/#html-blocks.
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|search|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.31.2/#uri-autolink
uriAutolinkRegexp = regexp.MustCompile(
`^<` + scheme + `:[^\x00-\x19 <>]*` + `>`)
// https://spec.commonmark.org/0.31.2/#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, lineNo := p.lines.next()
line, matchedContainers, newItem := p.tree.processContainerMarkers(line, lineNo, 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, lineNo, 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, lineNo, p.codec)
}
}
p.tree.closeParagraph(lineNo, p.codec)
} else if thematicBreakRegexp.MatchString(line) {
p.tree.closeBlocks(matchedContainers, lineNo, p.codec)
p.codec.Do(Op{Type: OpThematicBreak, LineNo: lineNo})
} else if m := atxHeadingRegexp.FindStringSubmatchIndex(line); m != nil {
p.tree.closeBlocks(matchedContainers, lineNo, 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, LineNo: lineNo, Number: level, Info: attr,
Content: renderInline(strings.Trim(line, " \t"))})
} else if m := codeFenceRegexp.FindStringSubmatch(line); m != nil {
p.tree.closeBlocks(matchedContainers, lineNo, 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, lineNo, p.codec)
p.parseIndentedCodeBlock(line)
} else if html1Regexp.MatchString(line) {
p.tree.closeBlocks(matchedContainers, lineNo, p.codec)
p.parseCloserTerminatedHTMLBlock(line, html1CloserRegexp.MatchString)
} else if html2Regexp.MatchString(line) {
p.tree.closeBlocks(matchedContainers, lineNo, p.codec)
p.parseCloserTerminatedHTMLBlock(line, html2CloserRegexp.MatchString)
} else if html3Regexp.MatchString(line) {
p.tree.closeBlocks(matchedContainers, lineNo, p.codec)
p.parseCloserTerminatedHTMLBlock(line, html3CloserRegexp.MatchString)
} else if html4Regexp.MatchString(line) {
p.tree.closeBlocks(matchedContainers, lineNo, p.codec)
p.parseCloserTerminatedHTMLBlock(line, html4CloserRegexp.MatchString)
} else if html5Regexp.MatchString(line) {
p.tree.closeBlocks(matchedContainers, lineNo, p.codec)
p.parseCloserTerminatedHTMLBlock(line, html5CloserRegexp.MatchString)
} else if html6Regexp.MatchString(line) || (len(p.tree.paragraph) == 0 && html7Regexp.MatchString(line)) {
p.tree.closeBlocks(matchedContainers, lineNo, 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, lineNo, p.codec)
}
p.tree.paragraph = append(p.tree.paragraph, line)
}
}
p.tree.closeBlocks(0, p.lines.lastLineNo+1, 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
startLineNo := p.lines.lastLineNo
doCodeBlock := func() {
p.codec.Do(Op{Type: OpCodeBlock, LineNo: startLineNo, Info: info, Lines: lines})
}
for p.lines.more() {
line, lineNo := 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, lineNo, 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)}
startLineNo := p.lines.lastLineNo
doCodeBlock := func() { p.codec.Do(Op{Type: OpCodeBlock, LineNo: startLineNo, Lines: lines}) }
var savedBlankLines []string
for p.lines.more() {
line, lineNo := 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, lineNo, 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}
startLineNo := p.lines.lastLineNo
doHTMLBlock := func() {
p.codec.Do(Op{Type: OpHTMLBlock, LineNo: startLineNo, Lines: lines})
}
if closer(line) {
doHTMLBlock()
return
}
var savedBlankLines []string
for p.lines.more() {
line, lineNo := 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, lineNo, 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}
startLineNo := p.lines.lastLineNo
doHTMLBlock := func() { p.codec.Do(Op{Type: OpHTMLBlock, LineNo: startLineNo, Lines: lines}) }
for p.lines.more() {
line, lineNo := 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, lineNo, 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.31.2/#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, lineNo int, 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, lineNo, 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], LineNo: lineNo, Number: list.start})
}
}
t.containers = append(t.containers, c)
codec.Do(Op{Type: containerOpenOp[c.typ], LineNo: lineNo})
}
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.31.2/#block-quotes
blockquoteMarkerRegexp = regexp.MustCompile(`^ {0,3}> ?`)
// Rule #1 and #2 of https://spec.commonmark.org/0.31.2/#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.31.2/#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.31.2/#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, lineNo int, codec Codec) {
t.closeParagraph(lineNo, codec)
for i := len(t.containers) - 1; i >= keep; i-- {
codec.Do(Op{Type: containerCloseOp[t.containers[i].typ], LineNo: lineNo})
}
t.containers = t.containers[:keep]
}
// lineNo identifies the first line not part of the paragraph.
func (t *blockTree) closeParagraph(lineNo int, codec Codec) {
if len(t.paragraph) == 0 {
return
}
startLineNo := lineNo - len(t.paragraph)
text := strings.Trim(strings.Join(t.paragraph, "\n"), " \t")
t.paragraph = t.paragraph[:0]
codec.Do(Op{Type: OpParagraph, LineNo: startLineNo, 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
// Line number of the last line returned by next.
lastLineNo int
}
func (s *lineSplitter) more() bool {
return s.pos < len(s.text)
}
func (s *lineSplitter) next() (string, int) {
begin := s.pos
delta := strings.IndexByte(s.text[begin:], '\n')
if delta == -1 {
s.pos = len(s.text)
s.lastLineNo++
return s.text[begin:], s.lastLineNo
}
s.pos += delta + 1
s.lastLineNo++
return s.text[begin : s.pos-1], s.lastLineNo
}
func (s *lineSplitter) backup() {
if s.pos == 0 {
return
}
s.pos = 1 + strings.LastIndexByte(s.text[:s.pos-1], '\n')
s.lastLineNo--
}
var leftAnchoredCharRefRegexp = regexp.MustCompile(`^` + charRefPattern)
func leadingCharRef(s string) string {
return leftAnchoredCharRefRegexp.FindString(s)
}
elvish-0.21.0/pkg/md/md_test.go 0000664 0000000 0000000 00000002603 14657203754 0016251 0 ustar 00root root 0000000 0000000 package md_test
import (
"fmt"
"strings"
"testing"
"src.elv.sh/pkg/diff"
"src.elv.sh/pkg/md"
"src.elv.sh/pkg/testutil"
)
// Most of the parser behavior is tested indirectly via the HTML output. This
// file only covers behavior not observable from the HTML output.
type lineNoCodec struct{ strings.Builder }
func (c *lineNoCodec) Do(op md.Op) {
fmt.Fprintln(&c.Builder, op.Type, op.LineNo)
}
var lineNoTestInput = testutil.Dedent(`
---
# line 3
~~~line 5
foo
~~~
line 9
foo
line 12
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.21.0/pkg/md/spec/ 0000775 0000000 0000000 00000000000 14657203754 0015214 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/spec/LICENSE 0000664 0000000 0000000 00000000352 14657203754 0016221 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": 355,
"end_line": 360,
"section": "Tabs"
},
{
"markdown": " \tfoo\tbaz\t\tbim\n",
"html": "foo\tbaz\t\tbim\n
\n",
"example": 2,
"start_line": 362,
"end_line": 367,
"section": "Tabs"
},
{
"markdown": " a\ta\n ὐ\ta\n",
"html": "a\ta\nὐ\ta\n
\n",
"example": 3,
"start_line": 369,
"end_line": 376,
"section": "Tabs"
},
{
"markdown": " - foo\n\n\tbar\n",
"html": "foo
\nbar
\nfoo
\n bar\n
\n\n\n", "example": 6, "start_line": 418, "end_line": 425, "section": "Tabs" }, { "markdown": "-\t\tfoo\n", "html": "\nfoo\n
foo\n
\nfoo\nbar\n
\n",
"example": 8,
"start_line": 439,
"end_line": 446,
"section": "Tabs"
},
{
"markdown": " - foo\n - bar\n\t - baz\n",
"html": "!"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~
\n", "example": 12, "start_line": 489, "end_line": 493, "section": "Backslash escapes" }, { "markdown": "\\\t\\A\\a\\ \\3\\φ\\«\n", "html": "\\\t\\A\\a\\ \\3\\φ\\«
\n", "example": 13, "start_line": 499, "end_line": 503, "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": 509, "end_line": 529, "section": "Backslash escapes" }, { "markdown": "\\\\*emphasis*\n", "html": "\\emphasis
\n", "example": 15, "start_line": 534, "end_line": 538, "section": "Backslash escapes" }, { "markdown": "foo\\\nbar\n", "html": "foo
\nbar
\\[\\`
\\[\\]\n
\n",
"example": 18,
"start_line": 562,
"end_line": 567,
"section": "Backslash escapes"
},
{
"markdown": "~~~\n\\[\\]\n~~~\n",
"html": "\\[\\]\n
\n",
"example": 19,
"start_line": 570,
"end_line": 577,
"section": "Backslash escapes"
},
{
"markdown": "foo\n
\n",
"example": 24,
"start_line": 613,
"end_line": 620,
"section": "Backslash escapes"
},
{
"markdown": " & © Æ Ď\n¾ ℋ ⅆ\n∲ ≧̸\n",
"html": "& © Æ Ď\n¾ ℋ ⅆ\n∲ ≧̸
\n", "example": 25, "start_line": 649, "end_line": 657, "section": "Entity and numeric character references" }, { "markdown": "# Ӓ Ϡ \n", "html": "# Ӓ Ϡ �
\n", "example": 26, "start_line": 668, "end_line": 672, "section": "Entity and numeric character references" }, { "markdown": "" ആ ಫ\n", "html": "" ആ ಫ
\n", "example": 27, "start_line": 681, "end_line": 685, "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": 690, "end_line": 700, "section": "Entity and numeric character references" }, { "markdown": "©\n", "html": "©
\n", "example": 29, "start_line": 707, "end_line": 711, "section": "Entity and numeric character references" }, { "markdown": "&MadeUpEntity;\n", "html": "&MadeUpEntity;
\n", "example": 30, "start_line": 717, "end_line": 721, "section": "Entity and numeric character references" }, { "markdown": "\n", "html": "\n", "example": 31, "start_line": 728, "end_line": 732, "section": "Entity and numeric character references" }, { "markdown": "[foo](/föö \"föö\")\n", "html": "\n", "example": 32, "start_line": 735, "end_line": 739, "section": "Entity and numeric character references" }, { "markdown": "[foo]\n\n[foo]: /föö \"föö\"\n", "html": "\n", "example": 33, "start_line": 742, "end_line": 748, "section": "Entity and numeric character references" }, { "markdown": "``` föö\nfoo\n```\n", "html": "foo\n
\n",
"example": 34,
"start_line": 751,
"end_line": 758,
"section": "Entity and numeric character references"
},
{
"markdown": "`föö`\n",
"html": "föö
föfö\n
\n",
"example": 36,
"start_line": 771,
"end_line": 776,
"section": "Entity and numeric character references"
},
{
"markdown": "*foo*\n*foo*\n",
"html": "*foo*\nfoo
\n", "example": 37, "start_line": 783, "end_line": 789, "section": "Entity and numeric character references" }, { "markdown": "* foo\n\n* foo\n", "html": "* foo
\nfoo\n\nbar
\n", "example": 39, "start_line": 802, "end_line": 808, "section": "Entity and numeric character references" }, { "markdown": " foo\n", "html": "\tfoo
\n", "example": 40, "start_line": 810, "end_line": 814, "section": "Entity and numeric character references" }, { "markdown": "[a](url "tit")\n", "html": "[a](url "tit")
\n", "example": 41, "start_line": 817, "end_line": 821, "section": "Entity and numeric character references" }, { "markdown": "- `one\n- two`\n", "html": "+++
\n", "example": 44, "start_line": 892, "end_line": 896, "section": "Thematic breaks" }, { "markdown": "===\n", "html": "===
\n", "example": 45, "start_line": 899, "end_line": 903, "section": "Thematic breaks" }, { "markdown": "--\n**\n__\n", "html": "--\n**\n__
\n", "example": 46, "start_line": 908, "end_line": 916, "section": "Thematic breaks" }, { "markdown": " ***\n ***\n ***\n", "html": "***\n
\n",
"example": 48,
"start_line": 934,
"end_line": 939,
"section": "Thematic breaks"
},
{
"markdown": "Foo\n ***\n",
"html": "Foo\n***
\n", "example": 49, "start_line": 942, "end_line": 948, "section": "Thematic breaks" }, { "markdown": "_____________________________________\n", "html": "_ _ _ _ a
\na------
\n---a---
\n", "example": 55, "start_line": 994, "end_line": 1004, "section": "Thematic breaks" }, { "markdown": " *-*\n", "html": "-
\n", "example": 56, "start_line": 1010, "end_line": 1014, "section": "Thematic breaks" }, { "markdown": "- foo\n***\n- bar\n", "html": "Foo
\nbar
\n", "example": 58, "start_line": 1036, "end_line": 1044, "section": "Thematic breaks" }, { "markdown": "Foo\n---\nbar\n", "html": "bar
\n", "example": 59, "start_line": 1053, "end_line": 1060, "section": "Thematic breaks" }, { "markdown": "* Foo\n* * *\n* Bar\n", "html": "####### foo
\n", "example": 63, "start_line": 1131, "end_line": 1135, "section": "ATX headings" }, { "markdown": "#5 bolt\n\n#hashtag\n", "html": "#5 bolt
\n#hashtag
\n", "example": 64, "start_line": 1146, "end_line": 1153, "section": "ATX headings" }, { "markdown": "\\## foo\n", "html": "## foo
\n", "example": 65, "start_line": 1158, "end_line": 1162, "section": "ATX headings" }, { "markdown": "# foo *bar* \\*baz\\*\n", "html": "# foo\n
\n",
"example": 69,
"start_line": 1198,
"end_line": 1203,
"section": "ATX headings"
},
{
"markdown": "foo\n # bar\n",
"html": "foo\n# bar
\n", "example": 70, "start_line": 1206, "end_line": 1212, "section": "ATX headings" }, { "markdown": "## foo ##\n ### bar ###\n", "html": "Foo bar
\nBar foo
\n", "example": 78, "start_line": 1294, "end_line": 1302, "section": "ATX headings" }, { "markdown": "## \n#\n### ###\n", "html": "\n\n\n", "example": 79, "start_line": 1307, "end_line": 1315, "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": 1449, "end_line": 1455, "section": "Setext headings" }, { "markdown": "Foo\n= =\n\nFoo\n--- -\n", "html": "Foo\n= =
\nFoo
\n`
\nof dashes"/>
\n", "example": 91, "start_line": 1497, "end_line": 1510, "section": "Setext headings" }, { "markdown": "> Foo\n---\n", "html": "\n\nFoo
\n
\n\n", "example": 93, "start_line": 1527, "end_line": 1537, "section": "Setext headings" }, { "markdown": "- Foo\n---\n", "html": "foo\nbar\n===
\n
Baz
\n", "example": 96, "start_line": 1568, "end_line": 1580, "section": "Setext headings" }, { "markdown": "\n====\n", "html": "====
\n", "example": 97, "start_line": 1585, "end_line": 1590, "section": "Setext headings" }, { "markdown": "---\n---\n", "html": "foo\n
\n\n\nfoo
\n
Foo
\nbaz
\n", "example": 103, "start_line": 1672, "end_line": 1682, "section": "Setext headings" }, { "markdown": "Foo\nbar\n\n---\n\nbaz\n", "html": "Foo\nbar
\nbaz
\n", "example": 104, "start_line": 1688, "end_line": 1700, "section": "Setext headings" }, { "markdown": "Foo\nbar\n* * *\nbaz\n", "html": "Foo\nbar
\nbaz
\n", "example": 105, "start_line": 1706, "end_line": 1716, "section": "Setext headings" }, { "markdown": "Foo\nbar\n\\---\nbaz\n", "html": "Foo\nbar\n---\nbaz
\n", "example": 106, "start_line": 1721, "end_line": 1731, "section": "Setext headings" }, { "markdown": " a simple\n indented code block\n", "html": "a simple\n indented code block\n
\n",
"example": 107,
"start_line": 1749,
"end_line": 1756,
"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": 1797,
"end_line": 1808,
"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": 1813,
"end_line": 1830,
"section": "Indented code blocks"
},
{
"markdown": " chunk1\n \n chunk2\n",
"html": "chunk1\n \n chunk2\n
\n",
"example": 112,
"start_line": 1836,
"end_line": 1845,
"section": "Indented code blocks"
},
{
"markdown": "Foo\n bar\n\n",
"html": "Foo\nbar
\n", "example": 113, "start_line": 1851, "end_line": 1858, "section": "Indented code blocks" }, { "markdown": " foo\nbar\n", "html": "foo\n
\nbar
\n", "example": 114, "start_line": 1865, "end_line": 1872, "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": 1898,
"end_line": 1905,
"section": "Indented code blocks"
},
{
"markdown": "\n \n foo\n \n\n",
"html": "foo\n
\n",
"example": 117,
"start_line": 1911,
"end_line": 1920,
"section": "Indented code blocks"
},
{
"markdown": " foo \n",
"html": "foo \n
\n",
"example": 118,
"start_line": 1925,
"end_line": 1930,
"section": "Indented code blocks"
},
{
"markdown": "```\n<\n >\n```\n",
"html": "<\n >\n
\n",
"example": 119,
"start_line": 1980,
"end_line": 1989,
"section": "Fenced code blocks"
},
{
"markdown": "~~~\n<\n >\n~~~\n",
"html": "<\n >\n
\n",
"example": 120,
"start_line": 1994,
"end_line": 2003,
"section": "Fenced code blocks"
},
{
"markdown": "``\nfoo\n``\n",
"html": "foo
aaa\n~~~\n
\n",
"example": 122,
"start_line": 2018,
"end_line": 2027,
"section": "Fenced code blocks"
},
{
"markdown": "~~~\naaa\n```\n~~~\n",
"html": "aaa\n```\n
\n",
"example": 123,
"start_line": 2030,
"end_line": 2039,
"section": "Fenced code blocks"
},
{
"markdown": "````\naaa\n```\n``````\n",
"html": "aaa\n```\n
\n",
"example": 124,
"start_line": 2044,
"end_line": 2053,
"section": "Fenced code blocks"
},
{
"markdown": "~~~~\naaa\n~~~\n~~~~\n",
"html": "aaa\n~~~\n
\n",
"example": 125,
"start_line": 2056,
"end_line": 2065,
"section": "Fenced code blocks"
},
{
"markdown": "```\n",
"html": "
\n",
"example": 126,
"start_line": 2071,
"end_line": 2075,
"section": "Fenced code blocks"
},
{
"markdown": "`````\n\n```\naaa\n",
"html": "\n```\naaa\n
\n",
"example": 127,
"start_line": 2078,
"end_line": 2088,
"section": "Fenced code blocks"
},
{
"markdown": "> ```\n> aaa\n\nbbb\n",
"html": "\n\n\naaa\n
bbb
\n", "example": 128, "start_line": 2091, "end_line": 2102, "section": "Fenced code blocks" }, { "markdown": "```\n\n \n```\n", "html": "\n \n
\n",
"example": 129,
"start_line": 2107,
"end_line": 2116,
"section": "Fenced code blocks"
},
{
"markdown": "```\n```\n",
"html": "
\n",
"example": 130,
"start_line": 2121,
"end_line": 2126,
"section": "Fenced code blocks"
},
{
"markdown": " ```\n aaa\naaa\n```\n",
"html": "aaa\naaa\n
\n",
"example": 131,
"start_line": 2133,
"end_line": 2142,
"section": "Fenced code blocks"
},
{
"markdown": " ```\naaa\n aaa\naaa\n ```\n",
"html": "aaa\naaa\naaa\n
\n",
"example": 132,
"start_line": 2145,
"end_line": 2156,
"section": "Fenced code blocks"
},
{
"markdown": " ```\n aaa\n aaa\n aaa\n ```\n",
"html": "aaa\n aaa\naaa\n
\n",
"example": 133,
"start_line": 2159,
"end_line": 2170,
"section": "Fenced code blocks"
},
{
"markdown": " ```\n aaa\n ```\n",
"html": "```\naaa\n```\n
\n",
"example": 134,
"start_line": 2175,
"end_line": 2184,
"section": "Fenced code blocks"
},
{
"markdown": "```\naaa\n ```\n",
"html": "aaa\n
\n",
"example": 135,
"start_line": 2190,
"end_line": 2197,
"section": "Fenced code blocks"
},
{
"markdown": " ```\naaa\n ```\n",
"html": "aaa\n
\n",
"example": 136,
"start_line": 2200,
"end_line": 2207,
"section": "Fenced code blocks"
},
{
"markdown": "```\naaa\n ```\n",
"html": "aaa\n ```\n
\n",
"example": 137,
"start_line": 2212,
"end_line": 2220,
"section": "Fenced code blocks"
},
{
"markdown": "``` ```\naaa\n",
"html": "
\naaa
aaa\n~~~ ~~\n
\n",
"example": 139,
"start_line": 2235,
"end_line": 2243,
"section": "Fenced code blocks"
},
{
"markdown": "foo\n```\nbar\n```\nbaz\n",
"html": "foo
\nbar\n
\nbaz
\n", "example": 140, "start_line": 2249, "end_line": 2260, "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": 2288,
"end_line": 2299,
"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": 2302,
"end_line": 2313,
"section": "Fenced code blocks"
},
{
"markdown": "````;\n````\n",
"html": "
\n",
"example": 144,
"start_line": 2316,
"end_line": 2321,
"section": "Fenced code blocks"
},
{
"markdown": "``` aa ```\nfoo\n",
"html": "aa
\nfoo
foo\n
\n",
"example": 146,
"start_line": 2337,
"end_line": 2344,
"section": "Fenced code blocks"
},
{
"markdown": "```\n``` aaa\n```\n",
"html": "``` aaa\n
\n",
"example": 147,
"start_line": 2349,
"end_line": 2356,
"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": 2457, "end_line": 2476, "section": "HTML blocks" }, { "markdown": "Markdown
\nbar
\n", "example": 155, "start_line": 2542, "end_line": 2551, "section": "HTML blocks" }, { "markdown": "\n", "html": "\n", "example": 159, "start_line": 2591, "end_line": 2595, "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": 2731, "end_line": 2747, "section": "HTML blocks" }, { "markdown": "\nokay\n", "html": "\nokay
\n", "example": 170, "start_line": 2752, "end_line": 2766, "section": "HTML blocks" }, { "markdown": "\n", "html": "\n", "example": 171, "start_line": 2771, "end_line": 2787, "section": "HTML blocks" }, { "markdown": "\nokay\n", "html": "\nokay
\n", "example": 172, "start_line": 2791, "end_line": 2807, "section": "HTML blocks" }, { "markdown": "\n*foo*\n", "html": "\nfoo
\n", "example": 176, "start_line": 2856, "end_line": 2862, "section": "HTML blocks" }, { "markdown": "*bar*\n*baz*\n", "html": "*bar*\nbaz
\n", "example": 177, "start_line": 2865, "end_line": 2871, "section": "HTML blocks" }, { "markdown": "1. *bar*\n", "html": "1. *bar*\n", "example": 178, "start_line": 2877, "end_line": 2885, "section": "HTML blocks" }, { "markdown": "\nokay\n", "html": "\nokay
\n", "example": 179, "start_line": 2890, "end_line": 2902, "section": "HTML blocks" }, { "markdown": "';\n\n?>\nokay\n", "html": "';\n\n?>\nokay
\n", "example": 180, "start_line": 2908, "end_line": 2922, "section": "HTML blocks" }, { "markdown": "\n", "html": "\n", "example": 181, "start_line": 2927, "end_line": 2931, "section": "HTML blocks" }, { "markdown": "\nokay\n", "html": "\nokay
\n", "example": 182, "start_line": 2936, "end_line": 2964, "section": "HTML blocks" }, { "markdown": " \n\n \n", "html": " \n<!-- foo -->\n
\n",
"example": 183,
"start_line": 2970,
"end_line": 2978,
"section": "HTML blocks"
},
{
"markdown": " <div>\n
\n",
"example": 184,
"start_line": 2981,
"end_line": 2989,
"section": "HTML blocks"
},
{
"markdown": "Foo\nFoo
\nFoo\n\nbaz
\n", "example": 187, "start_line": 3027, "end_line": 3035, "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": 3240, "end_line": 3250, "section": "Link reference definitions" }, { "markdown": "[foo]:\n/url\n\n[foo]\n", "html": "\n", "example": 198, "start_line": 3255, "end_line": 3262, "section": "Link reference definitions" }, { "markdown": "[foo]:\n\n[foo]\n", "html": "[foo]:
\n[foo]
\n", "example": 199, "start_line": 3267, "end_line": 3274, "section": "Link reference definitions" }, { "markdown": "[foo]: <>\n\n[foo]\n", "html": "\n", "example": 200, "start_line": 3279, "end_line": 3285, "section": "Link reference definitions" }, { "markdown": "[foo]:[foo]:
[foo]
\n", "example": 201, "start_line": 3290, "end_line": 3297, "section": "Link reference definitions" }, { "markdown": "[foo]: /url\\bar\\*baz \"foo\\\"bar\\baz\"\n\n[foo]\n", "html": "\n", "example": 202, "start_line": 3303, "end_line": 3309, "section": "Link reference definitions" }, { "markdown": "[foo]\n\n[foo]: url\n", "html": "\n", "example": 203, "start_line": 3314, "end_line": 3320, "section": "Link reference definitions" }, { "markdown": "[foo]\n\n[foo]: first\n[foo]: second\n", "html": "\n", "example": 204, "start_line": 3326, "end_line": 3333, "section": "Link reference definitions" }, { "markdown": "[FOO]: /url\n\n[Foo]\n", "html": "\n", "example": 205, "start_line": 3339, "end_line": 3345, "section": "Link reference definitions" }, { "markdown": "[ΑΓΩ]: /φου\n\n[αγω]\n", "html": "\n", "example": 206, "start_line": 3348, "end_line": 3354, "section": "Link reference definitions" }, { "markdown": "[foo]: /url\n", "html": "", "example": 207, "start_line": 3363, "end_line": 3366, "section": "Link reference definitions" }, { "markdown": "[\nfoo\n]: /url\nbar\n", "html": "bar
\n", "example": 208, "start_line": 3371, "end_line": 3378, "section": "Link reference definitions" }, { "markdown": "[foo]: /url \"title\" ok\n", "html": "[foo]: /url "title" ok
\n", "example": 209, "start_line": 3384, "end_line": 3388, "section": "Link reference definitions" }, { "markdown": "[foo]: /url\n\"title\" ok\n", "html": ""title" ok
\n", "example": 210, "start_line": 3393, "end_line": 3398, "section": "Link reference definitions" }, { "markdown": " [foo]: /url \"title\"\n\n[foo]\n", "html": "[foo]: /url "title"\n
\n[foo]
\n", "example": 211, "start_line": 3404, "end_line": 3412, "section": "Link reference definitions" }, { "markdown": "```\n[foo]: /url\n```\n\n[foo]\n", "html": "[foo]: /url\n
\n[foo]
\n", "example": 212, "start_line": 3418, "end_line": 3428, "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": 3433, "end_line": 3442, "section": "Link reference definitions" }, { "markdown": "# [Foo]\n[foo]: /url\n> bar\n", "html": "\n\n", "example": 214, "start_line": 3448, "end_line": 3457, "section": "Link reference definitions" }, { "markdown": "[foo]: /url\nbar\n===\n[foo]\n", "html": "bar
\n
===\nfoo
\n", "example": 216, "start_line": 3469, "end_line": 3476, "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": 3482, "end_line": 3495, "section": "Link reference definitions" }, { "markdown": "[foo]\n\n> [foo]: /url\n", "html": "\n\n\n", "example": 218, "start_line": 3503, "end_line": 3511, "section": "Link reference definitions" }, { "markdown": "aaa\n\nbbb\n", "html": "
aaa
\nbbb
\n", "example": 219, "start_line": 3525, "end_line": 3532, "section": "Paragraphs" }, { "markdown": "aaa\nbbb\n\nccc\nddd\n", "html": "aaa\nbbb
\nccc\nddd
\n", "example": 220, "start_line": 3537, "end_line": 3548, "section": "Paragraphs" }, { "markdown": "aaa\n\n\nbbb\n", "html": "aaa
\nbbb
\n", "example": 221, "start_line": 3553, "end_line": 3561, "section": "Paragraphs" }, { "markdown": " aaa\n bbb\n", "html": "aaa\nbbb
\n", "example": 222, "start_line": 3566, "end_line": 3572, "section": "Paragraphs" }, { "markdown": "aaa\n bbb\n ccc\n", "html": "aaa\nbbb\nccc
\n", "example": 223, "start_line": 3578, "end_line": 3586, "section": "Paragraphs" }, { "markdown": " aaa\nbbb\n", "html": "aaa\nbbb
\n", "example": 224, "start_line": 3592, "end_line": 3598, "section": "Paragraphs" }, { "markdown": " aaa\nbbb\n", "html": "aaa\n
\nbbb
\n", "example": 225, "start_line": 3601, "end_line": 3608, "section": "Paragraphs" }, { "markdown": "aaa \nbbb \n", "html": "aaa
\nbbb
aaa
\n\n\n", "example": 228, "start_line": 3700, "end_line": 3710, "section": "Block quotes" }, { "markdown": "># Foo\n>bar\n> baz\n", "html": "Foo
\nbar\nbaz
\n
\n\n", "example": 229, "start_line": 3715, "end_line": 3725, "section": "Block quotes" }, { "markdown": " > # Foo\n > bar\n > baz\n", "html": "Foo
\nbar\nbaz
\n
\n\n", "example": 230, "start_line": 3730, "end_line": 3740, "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": 3745,
"end_line": 3754,
"section": "Block quotes"
},
{
"markdown": "> # Foo\n> bar\nbaz\n",
"html": "\n\n", "example": 232, "start_line": 3760, "end_line": 3770, "section": "Block quotes" }, { "markdown": "> bar\nbaz\n> foo\n", "html": "Foo
\nbar\nbaz
\n
\n\n", "example": 233, "start_line": 3776, "end_line": 3786, "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": 3838,
"end_line": 3848,
"section": "Block quotes"
},
{
"markdown": "> ```\nfoo\n```\n",
"html": "\n\n\n
foo
\n
\n",
"example": 237,
"start_line": 3851,
"end_line": 3861,
"section": "Block quotes"
},
{
"markdown": "> foo\n - bar\n",
"html": "\n\n", "example": 238, "start_line": 3867, "end_line": 3875, "section": "Block quotes" }, { "markdown": ">\n", "html": "foo\n- bar
\n
\n\n", "example": 239, "start_line": 3891, "end_line": 3896, "section": "Block quotes" }, { "markdown": ">\n> \n> \n", "html": "
\n\n", "example": 240, "start_line": 3899, "end_line": 3906, "section": "Block quotes" }, { "markdown": ">\n> foo\n> \n", "html": "
\n\n", "example": 241, "start_line": 3911, "end_line": 3919, "section": "Block quotes" }, { "markdown": "> foo\n\n> bar\n", "html": "foo
\n
\n\nfoo
\n
\n\n", "example": 242, "start_line": 3924, "end_line": 3935, "section": "Block quotes" }, { "markdown": "> foo\n> bar\n", "html": "bar
\n
\n\n", "example": 243, "start_line": 3946, "end_line": 3954, "section": "Block quotes" }, { "markdown": "> foo\n>\n> bar\n", "html": "foo\nbar
\n
\n\n", "example": 244, "start_line": 3959, "end_line": 3968, "section": "Block quotes" }, { "markdown": "foo\n> bar\n", "html": "foo
\nbar
\n
foo
\n\n\n", "example": 245, "start_line": 3973, "end_line": 3981, "section": "Block quotes" }, { "markdown": "> aaa\n***\n> bbb\n", "html": "bar
\n
\n\naaa
\n
\n\n", "example": 246, "start_line": 3987, "end_line": 3999, "section": "Block quotes" }, { "markdown": "> bar\nbaz\n", "html": "bbb
\n
\n\n", "example": 247, "start_line": 4005, "end_line": 4013, "section": "Block quotes" }, { "markdown": "> bar\n\nbaz\n", "html": "bar\nbaz
\n
\n\nbar
\n
baz
\n", "example": 248, "start_line": 4016, "end_line": 4025, "section": "Block quotes" }, { "markdown": "> bar\n>\nbaz\n", "html": "\n\nbar
\n
baz
\n", "example": 249, "start_line": 4028, "end_line": 4037, "section": "Block quotes" }, { "markdown": "> > > foo\nbar\n", "html": "\n\n", "example": 250, "start_line": 4044, "end_line": 4056, "section": "Block quotes" }, { "markdown": ">>> foo\n> bar\n>>baz\n", "html": "\n\n\n\nfoo\nbar
\n
\n\n", "example": 251, "start_line": 4059, "end_line": 4073, "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": 4081, "end_line": 4093, "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": 4135, "end_line": 4150, "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": 4190, "end_line": 4199, "section": "List items" }, { "markdown": "- one\n\n two\n", "html": "one
\ntwo
\n two\n
\n",
"example": 257,
"start_line": 4216,
"end_line": 4226,
"section": "List items"
},
{
"markdown": " - one\n\n two\n",
"html": "one
\ntwo
\n\n\n", "example": 259, "start_line": 4251, "end_line": 4266, "section": "List items" }, { "markdown": ">>- one\n>>\n > > two\n", "html": "\n\n\n
\n- \n
\none
\ntwo
\n
\n\n", "example": 260, "start_line": 4278, "end_line": 4291, "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": 4297, "end_line": 4304, "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": 4386, "end_line": 4390, "section": "List items" }, { "markdown": "0. ok\n", "html": "-1. not ok
\n", "example": 269, "start_line": 4415, "end_line": 4419, "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": 4474,
"end_line": 4486,
"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": 4538, "end_line": 4545, "section": "List items" }, { "markdown": "- foo\n\n bar\n", "html": "bar
\n", "example": 276, "start_line": 4548, "end_line": 4557, "section": "List items" }, { "markdown": "- foo\n\n bar\n", "html": "foo
\nbar
\nbar\n
\nbaz\n
\nfoo
\n", "example": 280, "start_line": 4632, "end_line": 4641, "section": "List items" }, { "markdown": "- foo\n-\n- bar\n", "html": "foo\n*
\nfoo\n1.
\n", "example": 285, "start_line": 4701, "end_line": 4712, "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": 4795,
"end_line": 4810,
"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": 4862, "end_line": 4876, "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": 4879, "end_line": 4893, "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": 5360, "end_line": 5366, "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": 5456,
"end_line": 5479,
"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": 5552,
"end_line": 5569,
"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="
">`
<https://foo.bar.
baz>`
```foo``
\n", "example": 347, "start_line": 6075, "end_line": 6079, "section": "Code spans" }, { "markdown": "`foo\n", "html": "`foo
\n", "example": 348, "start_line": 6082, "end_line": 6086, "section": "Code spans" }, { "markdown": "`foo``bar``\n", "html": "`foobar
foo bar
\n", "example": 350, "start_line": 6308, "end_line": 6312, "section": "Emphasis and strong emphasis" }, { "markdown": "a * foo bar*\n", "html": "a * foo bar*
\n", "example": 351, "start_line": 6318, "end_line": 6322, "section": "Emphasis and strong emphasis" }, { "markdown": "a*\"foo\"*\n", "html": "a*"foo"*
\n", "example": 352, "start_line": 6329, "end_line": 6333, "section": "Emphasis and strong emphasis" }, { "markdown": "* a *\n", "html": "* a *
\n", "example": 353, "start_line": 6338, "end_line": 6342, "section": "Emphasis and strong emphasis" }, { "markdown": "*$*alpha.\n\n*£*bravo.\n\n*€*charlie.\n", "html": "*$*alpha.
\n*£*bravo.
\n*€*charlie.
\n", "example": 354, "start_line": 6347, "end_line": 6357, "section": "Emphasis and strong emphasis" }, { "markdown": "foo*bar*\n", "html": "foobar
\n", "example": 355, "start_line": 6362, "end_line": 6366, "section": "Emphasis and strong emphasis" }, { "markdown": "5*6*78\n", "html": "5678
\n", "example": 356, "start_line": 6369, "end_line": 6373, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo bar_\n", "html": "foo bar
\n", "example": 357, "start_line": 6378, "end_line": 6382, "section": "Emphasis and strong emphasis" }, { "markdown": "_ foo bar_\n", "html": "_ foo bar_
\n", "example": 358, "start_line": 6388, "end_line": 6392, "section": "Emphasis and strong emphasis" }, { "markdown": "a_\"foo\"_\n", "html": "a_"foo"_
\n", "example": 359, "start_line": 6398, "end_line": 6402, "section": "Emphasis and strong emphasis" }, { "markdown": "foo_bar_\n", "html": "foo_bar_
\n", "example": 360, "start_line": 6407, "end_line": 6411, "section": "Emphasis and strong emphasis" }, { "markdown": "5_6_78\n", "html": "5_6_78
\n", "example": 361, "start_line": 6414, "end_line": 6418, "section": "Emphasis and strong emphasis" }, { "markdown": "пристаням_стремятся_\n", "html": "пристаням_стремятся_
\n", "example": 362, "start_line": 6421, "end_line": 6425, "section": "Emphasis and strong emphasis" }, { "markdown": "aa_\"bb\"_cc\n", "html": "aa_"bb"_cc
\n", "example": 363, "start_line": 6431, "end_line": 6435, "section": "Emphasis and strong emphasis" }, { "markdown": "foo-_(bar)_\n", "html": "foo-(bar)
\n", "example": 364, "start_line": 6442, "end_line": 6446, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo*\n", "html": "_foo*
\n", "example": 365, "start_line": 6454, "end_line": 6458, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo bar *\n", "html": "*foo bar *
\n", "example": 366, "start_line": 6464, "end_line": 6468, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo bar\n*\n", "html": "*foo bar\n*
\n", "example": 367, "start_line": 6473, "end_line": 6479, "section": "Emphasis and strong emphasis" }, { "markdown": "*(*foo)\n", "html": "*(*foo)
\n", "example": 368, "start_line": 6486, "end_line": 6490, "section": "Emphasis and strong emphasis" }, { "markdown": "*(*foo*)*\n", "html": "(foo)
\n", "example": 369, "start_line": 6496, "end_line": 6500, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo*bar\n", "html": "foobar
\n", "example": 370, "start_line": 6505, "end_line": 6509, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo bar _\n", "html": "_foo bar _
\n", "example": 371, "start_line": 6518, "end_line": 6522, "section": "Emphasis and strong emphasis" }, { "markdown": "_(_foo)\n", "html": "_(_foo)
\n", "example": 372, "start_line": 6528, "end_line": 6532, "section": "Emphasis and strong emphasis" }, { "markdown": "_(_foo_)_\n", "html": "(foo)
\n", "example": 373, "start_line": 6537, "end_line": 6541, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo_bar\n", "html": "_foo_bar
\n", "example": 374, "start_line": 6546, "end_line": 6550, "section": "Emphasis and strong emphasis" }, { "markdown": "_пристаням_стремятся\n", "html": "_пристаням_стремятся
\n", "example": 375, "start_line": 6553, "end_line": 6557, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo_bar_baz_\n", "html": "foo_bar_baz
\n", "example": 376, "start_line": 6560, "end_line": 6564, "section": "Emphasis and strong emphasis" }, { "markdown": "_(bar)_.\n", "html": "(bar).
\n", "example": 377, "start_line": 6571, "end_line": 6575, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo bar**\n", "html": "foo bar
\n", "example": 378, "start_line": 6580, "end_line": 6584, "section": "Emphasis and strong emphasis" }, { "markdown": "** foo bar**\n", "html": "** foo bar**
\n", "example": 379, "start_line": 6590, "end_line": 6594, "section": "Emphasis and strong emphasis" }, { "markdown": "a**\"foo\"**\n", "html": "a**"foo"**
\n", "example": 380, "start_line": 6601, "end_line": 6605, "section": "Emphasis and strong emphasis" }, { "markdown": "foo**bar**\n", "html": "foobar
\n", "example": 381, "start_line": 6610, "end_line": 6614, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo bar__\n", "html": "foo bar
\n", "example": 382, "start_line": 6619, "end_line": 6623, "section": "Emphasis and strong emphasis" }, { "markdown": "__ foo bar__\n", "html": "__ foo bar__
\n", "example": 383, "start_line": 6629, "end_line": 6633, "section": "Emphasis and strong emphasis" }, { "markdown": "__\nfoo bar__\n", "html": "__\nfoo bar__
\n", "example": 384, "start_line": 6637, "end_line": 6643, "section": "Emphasis and strong emphasis" }, { "markdown": "a__\"foo\"__\n", "html": "a__"foo"__
\n", "example": 385, "start_line": 6649, "end_line": 6653, "section": "Emphasis and strong emphasis" }, { "markdown": "foo__bar__\n", "html": "foo__bar__
\n", "example": 386, "start_line": 6658, "end_line": 6662, "section": "Emphasis and strong emphasis" }, { "markdown": "5__6__78\n", "html": "5__6__78
\n", "example": 387, "start_line": 6665, "end_line": 6669, "section": "Emphasis and strong emphasis" }, { "markdown": "пристаням__стремятся__\n", "html": "пристаням__стремятся__
\n", "example": 388, "start_line": 6672, "end_line": 6676, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo, __bar__, baz__\n", "html": "foo, bar, baz
\n", "example": 389, "start_line": 6679, "end_line": 6683, "section": "Emphasis and strong emphasis" }, { "markdown": "foo-__(bar)__\n", "html": "foo-(bar)
\n", "example": 390, "start_line": 6690, "end_line": 6694, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo bar **\n", "html": "**foo bar **
\n", "example": 391, "start_line": 6703, "end_line": 6707, "section": "Emphasis and strong emphasis" }, { "markdown": "**(**foo)\n", "html": "**(**foo)
\n", "example": 392, "start_line": 6716, "end_line": 6720, "section": "Emphasis and strong emphasis" }, { "markdown": "*(**foo**)*\n", "html": "(foo)
\n", "example": 393, "start_line": 6726, "end_line": 6730, "section": "Emphasis and strong emphasis" }, { "markdown": "**Gomphocarpus (*Gomphocarpus physocarpus*, syn.\n*Asclepias physocarpa*)**\n", "html": "Gomphocarpus (Gomphocarpus physocarpus, syn.\nAsclepias physocarpa)
\n", "example": 394, "start_line": 6733, "end_line": 6739, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo \"*bar*\" foo**\n", "html": "foo "bar" foo
\n", "example": 395, "start_line": 6742, "end_line": 6746, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo**bar\n", "html": "foobar
\n", "example": 396, "start_line": 6751, "end_line": 6755, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo bar __\n", "html": "__foo bar __
\n", "example": 397, "start_line": 6763, "end_line": 6767, "section": "Emphasis and strong emphasis" }, { "markdown": "__(__foo)\n", "html": "__(__foo)
\n", "example": 398, "start_line": 6773, "end_line": 6777, "section": "Emphasis and strong emphasis" }, { "markdown": "_(__foo__)_\n", "html": "(foo)
\n", "example": 399, "start_line": 6783, "end_line": 6787, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo__bar\n", "html": "__foo__bar
\n", "example": 400, "start_line": 6792, "end_line": 6796, "section": "Emphasis and strong emphasis" }, { "markdown": "__пристаням__стремятся\n", "html": "__пристаням__стремятся
\n", "example": 401, "start_line": 6799, "end_line": 6803, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo__bar__baz__\n", "html": "foo__bar__baz
\n", "example": 402, "start_line": 6806, "end_line": 6810, "section": "Emphasis and strong emphasis" }, { "markdown": "__(bar)__.\n", "html": "(bar).
\n", "example": 403, "start_line": 6817, "end_line": 6821, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo [bar](/url)*\n", "html": "foo bar
\n", "example": 404, "start_line": 6829, "end_line": 6833, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo\nbar*\n", "html": "foo\nbar
\n", "example": 405, "start_line": 6836, "end_line": 6842, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo __bar__ baz_\n", "html": "foo bar baz
\n", "example": 406, "start_line": 6848, "end_line": 6852, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo _bar_ baz_\n", "html": "foo bar baz
\n", "example": 407, "start_line": 6855, "end_line": 6859, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo_ bar_\n", "html": "foo bar
\n", "example": 408, "start_line": 6862, "end_line": 6866, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo *bar**\n", "html": "foo bar
\n", "example": 409, "start_line": 6869, "end_line": 6873, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo **bar** baz*\n", "html": "foo bar baz
\n", "example": 410, "start_line": 6876, "end_line": 6880, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo**bar**baz*\n", "html": "foobarbaz
\n", "example": 411, "start_line": 6882, "end_line": 6886, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo**bar*\n", "html": "foo**bar
\n", "example": 412, "start_line": 6906, "end_line": 6910, "section": "Emphasis and strong emphasis" }, { "markdown": "***foo** bar*\n", "html": "foo bar
\n", "example": 413, "start_line": 6919, "end_line": 6923, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo **bar***\n", "html": "foo bar
\n", "example": 414, "start_line": 6926, "end_line": 6930, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo**bar***\n", "html": "foobar
\n", "example": 415, "start_line": 6933, "end_line": 6937, "section": "Emphasis and strong emphasis" }, { "markdown": "foo***bar***baz\n", "html": "foobarbaz
\n", "example": 416, "start_line": 6944, "end_line": 6948, "section": "Emphasis and strong emphasis" }, { "markdown": "foo******bar*********baz\n", "html": "foobar***baz
\n", "example": 417, "start_line": 6950, "end_line": 6954, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo **bar *baz* bim** bop*\n", "html": "foo bar baz bim bop
\n", "example": 418, "start_line": 6959, "end_line": 6963, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo [*bar*](/url)*\n", "html": "foo bar
\n", "example": 419, "start_line": 6966, "end_line": 6970, "section": "Emphasis and strong emphasis" }, { "markdown": "** is not an empty emphasis\n", "html": "** is not an empty emphasis
\n", "example": 420, "start_line": 6975, "end_line": 6979, "section": "Emphasis and strong emphasis" }, { "markdown": "**** is not an empty strong emphasis\n", "html": "**** is not an empty strong emphasis
\n", "example": 421, "start_line": 6982, "end_line": 6986, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo [bar](/url)**\n", "html": "foo bar
\n", "example": 422, "start_line": 6995, "end_line": 6999, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo\nbar**\n", "html": "foo\nbar
\n", "example": 423, "start_line": 7002, "end_line": 7008, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo _bar_ baz__\n", "html": "foo bar baz
\n", "example": 424, "start_line": 7014, "end_line": 7018, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo __bar__ baz__\n", "html": "foo bar baz
\n", "example": 425, "start_line": 7021, "end_line": 7025, "section": "Emphasis and strong emphasis" }, { "markdown": "____foo__ bar__\n", "html": "foo bar
\n", "example": 426, "start_line": 7028, "end_line": 7032, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo **bar****\n", "html": "foo bar
\n", "example": 427, "start_line": 7035, "end_line": 7039, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo *bar* baz**\n", "html": "foo bar baz
\n", "example": 428, "start_line": 7042, "end_line": 7046, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo*bar*baz**\n", "html": "foobarbaz
\n", "example": 429, "start_line": 7049, "end_line": 7053, "section": "Emphasis and strong emphasis" }, { "markdown": "***foo* bar**\n", "html": "foo bar
\n", "example": 430, "start_line": 7056, "end_line": 7060, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo *bar***\n", "html": "foo bar
\n", "example": 431, "start_line": 7063, "end_line": 7067, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo *bar **baz**\nbim* bop**\n", "html": "foo bar baz\nbim bop
\n", "example": 432, "start_line": 7072, "end_line": 7078, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo [*bar*](/url)**\n", "html": "foo bar
\n", "example": 433, "start_line": 7081, "end_line": 7085, "section": "Emphasis and strong emphasis" }, { "markdown": "__ is not an empty emphasis\n", "html": "__ is not an empty emphasis
\n", "example": 434, "start_line": 7090, "end_line": 7094, "section": "Emphasis and strong emphasis" }, { "markdown": "____ is not an empty strong emphasis\n", "html": "____ is not an empty strong emphasis
\n", "example": 435, "start_line": 7097, "end_line": 7101, "section": "Emphasis and strong emphasis" }, { "markdown": "foo ***\n", "html": "foo ***
\n", "example": 436, "start_line": 7107, "end_line": 7111, "section": "Emphasis and strong emphasis" }, { "markdown": "foo *\\**\n", "html": "foo *
\n", "example": 437, "start_line": 7114, "end_line": 7118, "section": "Emphasis and strong emphasis" }, { "markdown": "foo *_*\n", "html": "foo _
\n", "example": 438, "start_line": 7121, "end_line": 7125, "section": "Emphasis and strong emphasis" }, { "markdown": "foo *****\n", "html": "foo *****
\n", "example": 439, "start_line": 7128, "end_line": 7132, "section": "Emphasis and strong emphasis" }, { "markdown": "foo **\\***\n", "html": "foo *
\n", "example": 440, "start_line": 7135, "end_line": 7139, "section": "Emphasis and strong emphasis" }, { "markdown": "foo **_**\n", "html": "foo _
\n", "example": 441, "start_line": 7142, "end_line": 7146, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo*\n", "html": "*foo
\n", "example": 442, "start_line": 7153, "end_line": 7157, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo**\n", "html": "foo*
\n", "example": 443, "start_line": 7160, "end_line": 7164, "section": "Emphasis and strong emphasis" }, { "markdown": "***foo**\n", "html": "*foo
\n", "example": 444, "start_line": 7167, "end_line": 7171, "section": "Emphasis and strong emphasis" }, { "markdown": "****foo*\n", "html": "***foo
\n", "example": 445, "start_line": 7174, "end_line": 7178, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo***\n", "html": "foo*
\n", "example": 446, "start_line": 7181, "end_line": 7185, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo****\n", "html": "foo***
\n", "example": 447, "start_line": 7188, "end_line": 7192, "section": "Emphasis and strong emphasis" }, { "markdown": "foo ___\n", "html": "foo ___
\n", "example": 448, "start_line": 7198, "end_line": 7202, "section": "Emphasis and strong emphasis" }, { "markdown": "foo _\\__\n", "html": "foo _
\n", "example": 449, "start_line": 7205, "end_line": 7209, "section": "Emphasis and strong emphasis" }, { "markdown": "foo _*_\n", "html": "foo *
\n", "example": 450, "start_line": 7212, "end_line": 7216, "section": "Emphasis and strong emphasis" }, { "markdown": "foo _____\n", "html": "foo _____
\n", "example": 451, "start_line": 7219, "end_line": 7223, "section": "Emphasis and strong emphasis" }, { "markdown": "foo __\\___\n", "html": "foo _
\n", "example": 452, "start_line": 7226, "end_line": 7230, "section": "Emphasis and strong emphasis" }, { "markdown": "foo __*__\n", "html": "foo *
\n", "example": 453, "start_line": 7233, "end_line": 7237, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo_\n", "html": "_foo
\n", "example": 454, "start_line": 7240, "end_line": 7244, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo__\n", "html": "foo_
\n", "example": 455, "start_line": 7251, "end_line": 7255, "section": "Emphasis and strong emphasis" }, { "markdown": "___foo__\n", "html": "_foo
\n", "example": 456, "start_line": 7258, "end_line": 7262, "section": "Emphasis and strong emphasis" }, { "markdown": "____foo_\n", "html": "___foo
\n", "example": 457, "start_line": 7265, "end_line": 7269, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo___\n", "html": "foo_
\n", "example": 458, "start_line": 7272, "end_line": 7276, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo____\n", "html": "foo___
\n", "example": 459, "start_line": 7279, "end_line": 7283, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo**\n", "html": "foo
\n", "example": 460, "start_line": 7289, "end_line": 7293, "section": "Emphasis and strong emphasis" }, { "markdown": "*_foo_*\n", "html": "foo
\n", "example": 461, "start_line": 7296, "end_line": 7300, "section": "Emphasis and strong emphasis" }, { "markdown": "__foo__\n", "html": "foo
\n", "example": 462, "start_line": 7303, "end_line": 7307, "section": "Emphasis and strong emphasis" }, { "markdown": "_*foo*_\n", "html": "foo
\n", "example": 463, "start_line": 7310, "end_line": 7314, "section": "Emphasis and strong emphasis" }, { "markdown": "****foo****\n", "html": "foo
\n", "example": 464, "start_line": 7320, "end_line": 7324, "section": "Emphasis and strong emphasis" }, { "markdown": "____foo____\n", "html": "foo
\n", "example": 465, "start_line": 7327, "end_line": 7331, "section": "Emphasis and strong emphasis" }, { "markdown": "******foo******\n", "html": "foo
\n", "example": 466, "start_line": 7338, "end_line": 7342, "section": "Emphasis and strong emphasis" }, { "markdown": "***foo***\n", "html": "foo
\n", "example": 467, "start_line": 7347, "end_line": 7351, "section": "Emphasis and strong emphasis" }, { "markdown": "_____foo_____\n", "html": "foo
\n", "example": 468, "start_line": 7354, "end_line": 7358, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo _bar* baz_\n", "html": "foo _bar baz_
\n", "example": 469, "start_line": 7363, "end_line": 7367, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo __bar *baz bim__ bam*\n", "html": "foo bar *baz bim bam
\n", "example": 470, "start_line": 7370, "end_line": 7374, "section": "Emphasis and strong emphasis" }, { "markdown": "**foo **bar baz**\n", "html": "**foo bar baz
\n", "example": 471, "start_line": 7379, "end_line": 7383, "section": "Emphasis and strong emphasis" }, { "markdown": "*foo *bar baz*\n", "html": "*foo bar baz
\n", "example": 472, "start_line": 7386, "end_line": 7390, "section": "Emphasis and strong emphasis" }, { "markdown": "*[bar*](/url)\n", "html": "*bar*
\n", "example": 473, "start_line": 7395, "end_line": 7399, "section": "Emphasis and strong emphasis" }, { "markdown": "_foo [bar_](/url)\n", "html": "_foo bar_
\n", "example": 474, "start_line": 7402, "end_line": 7406, "section": "Emphasis and strong emphasis" }, { "markdown": "**
a *
a _
[link](/my uri)
\n", "example": 488, "start_line": 7585, "end_line": 7589, "section": "Links" }, { "markdown": "[link]([link](foo\nbar)
\n", "example": 490, "start_line": 7600, "end_line": 7606, "section": "Links" }, { "markdown": "[link]([link](
[link](<foo>)
\n", "example": 493, "start_line": 7627, "end_line": 7631, "section": "Links" }, { "markdown": "[a](\n[a](c)\n", "html": "[a](<b)c\n[a](<b)c>\n[a](c)
\n", "example": 494, "start_line": 7636, "end_line": 7644, "section": "Links" }, { "markdown": "[link](\\(foo\\))\n", "html": "\n", "example": 495, "start_line": 7648, "end_line": 7652, "section": "Links" }, { "markdown": "[link](foo(and(bar)))\n", "html": "\n", "example": 496, "start_line": 7657, "end_line": 7661, "section": "Links" }, { "markdown": "[link](foo(and(bar))\n", "html": "[link](foo(and(bar))
\n", "example": 497, "start_line": 7666, "end_line": 7670, "section": "Links" }, { "markdown": "[link](foo\\(and\\(bar\\))\n", "html": "\n", "example": 498, "start_line": 7673, "end_line": 7677, "section": "Links" }, { "markdown": "[link]([link](/url "title "and" title")
\n", "example": 508, "start_line": 7785, "end_line": 7789, "section": "Links" }, { "markdown": "[link](/url 'title \"and\" title')\n", "html": "\n", "example": 509, "start_line": 7794, "end_line": 7798, "section": "Links" }, { "markdown": "[link]( /uri\n \"title\" )\n", "html": "\n", "example": 510, "start_line": 7819, "end_line": 7824, "section": "Links" }, { "markdown": "[link] (/uri)\n", "html": "[link] (/uri)
\n", "example": 511, "start_line": 7830, "end_line": 7834, "section": "Links" }, { "markdown": "[link [foo [bar]]](/uri)\n", "html": "\n", "example": 512, "start_line": 7840, "end_line": 7844, "section": "Links" }, { "markdown": "[link] bar](/uri)\n", "html": "[link] bar](/uri)
\n", "example": 513, "start_line": 7847, "end_line": 7851, "section": "Links" }, { "markdown": "[link [bar](/uri)\n", "html": "[link bar
\n", "example": 514, "start_line": 7854, "end_line": 7858, "section": "Links" }, { "markdown": "[link \\[bar](/uri)\n", "html": "\n", "example": 515, "start_line": 7861, "end_line": 7865, "section": "Links" }, { "markdown": "[link *foo **bar** `#`*](/uri)\n", "html": "\n", "example": 516, "start_line": 7870, "end_line": 7874, "section": "Links" }, { "markdown": "[](/uri)\n", "html": "\n", "example": 517, "start_line": 7877, "end_line": 7881, "section": "Links" }, { "markdown": "[foo [bar](/uri)](/uri)\n", "html": "[foo bar](/uri)
\n", "example": 518, "start_line": 7886, "end_line": 7890, "section": "Links" }, { "markdown": "[foo *[bar [baz](/uri)](/uri)*](/uri)\n", "html": "[foo [bar baz](/uri)](/uri)
\n", "example": 519, "start_line": 7893, "end_line": 7897, "section": "Links" }, { "markdown": "](uri2)](uri3)\n", "html": "*foo*
\n", "example": 521, "start_line": 7910, "end_line": 7914, "section": "Links" }, { "markdown": "[foo *bar](baz*)\n", "html": "\n", "example": 522, "start_line": 7917, "end_line": 7921, "section": "Links" }, { "markdown": "*foo [bar* baz]\n", "html": "foo [bar baz]
\n", "example": 523, "start_line": 7927, "end_line": 7931, "section": "Links" }, { "markdown": "[foo[foo
[foo](/uri)
[foohttps://example.com/?search=](uri)
\n", "example": 526, "start_line": 7951, "end_line": 7955, "section": "Links" }, { "markdown": "[foo][bar]\n\n[bar]: /url \"title\"\n", "html": "\n", "example": 527, "start_line": 7989, "end_line": 7995, "section": "Links" }, { "markdown": "[link [foo [bar]]][ref]\n\n[ref]: /uri\n", "html": "\n", "example": 528, "start_line": 8004, "end_line": 8010, "section": "Links" }, { "markdown": "[link \\[bar][ref]\n\n[ref]: /uri\n", "html": "\n", "example": 529, "start_line": 8013, "end_line": 8019, "section": "Links" }, { "markdown": "[link *foo **bar** `#`*][ref]\n\n[ref]: /uri\n", "html": "\n", "example": 530, "start_line": 8024, "end_line": 8030, "section": "Links" }, { "markdown": "[][ref]\n\n[ref]: /uri\n", "html": "\n", "example": 531, "start_line": 8033, "end_line": 8039, "section": "Links" }, { "markdown": "[foo [bar](/uri)][ref]\n\n[ref]: /uri\n", "html": "\n", "example": 532, "start_line": 8044, "end_line": 8050, "section": "Links" }, { "markdown": "[foo *bar [baz][ref]*][ref]\n\n[ref]: /uri\n", "html": "\n", "example": 533, "start_line": 8053, "end_line": 8059, "section": "Links" }, { "markdown": "*[foo*][ref]\n\n[ref]: /uri\n", "html": "*foo*
\n", "example": 534, "start_line": 8068, "end_line": 8074, "section": "Links" }, { "markdown": "[foo *bar][ref]*\n\n[ref]: /uri\n", "html": "\n", "example": 535, "start_line": 8077, "end_line": 8083, "section": "Links" }, { "markdown": "[foo[foo
[foo][ref]
[foohttps://example.com/?search=][ref]
\n", "example": 538, "start_line": 8107, "end_line": 8113, "section": "Links" }, { "markdown": "[foo][BaR]\n\n[bar]: /url \"title\"\n", "html": "\n", "example": 539, "start_line": 8118, "end_line": 8124, "section": "Links" }, { "markdown": "[ẞ]\n\n[SS]: /url\n", "html": "\n", "example": 540, "start_line": 8129, "end_line": 8135, "section": "Links" }, { "markdown": "[Foo\n bar]: /url\n\n[Baz][Foo bar]\n", "html": "\n", "example": 541, "start_line": 8141, "end_line": 8148, "section": "Links" }, { "markdown": "[foo] [bar]\n\n[bar]: /url \"title\"\n", "html": "[foo] bar
\n", "example": 542, "start_line": 8154, "end_line": 8160, "section": "Links" }, { "markdown": "[foo]\n[bar]\n\n[bar]: /url \"title\"\n", "html": "[foo]\nbar
\n", "example": 543, "start_line": 8163, "end_line": 8171, "section": "Links" }, { "markdown": "[foo]: /url1\n\n[foo]: /url2\n\n[bar][foo]\n", "html": "\n", "example": 544, "start_line": 8204, "end_line": 8212, "section": "Links" }, { "markdown": "[bar][foo\\!]\n\n[foo!]: /url\n", "html": "[bar][foo!]
\n", "example": 545, "start_line": 8219, "end_line": 8225, "section": "Links" }, { "markdown": "[foo][ref[]\n\n[ref[]: /uri\n", "html": "[foo][ref[]
\n[ref[]: /uri
\n", "example": 546, "start_line": 8231, "end_line": 8238, "section": "Links" }, { "markdown": "[foo][ref[bar]]\n\n[ref[bar]]: /uri\n", "html": "[foo][ref[bar]]
\n[ref[bar]]: /uri
\n", "example": 547, "start_line": 8241, "end_line": 8248, "section": "Links" }, { "markdown": "[[[foo]]]\n\n[[[foo]]]: /url\n", "html": "[[[foo]]]
\n[[[foo]]]: /url
\n", "example": 548, "start_line": 8251, "end_line": 8258, "section": "Links" }, { "markdown": "[foo][ref\\[]\n\n[ref\\[]: /uri\n", "html": "\n", "example": 549, "start_line": 8261, "end_line": 8267, "section": "Links" }, { "markdown": "[bar\\\\]: /uri\n\n[bar\\\\]\n", "html": "\n", "example": 550, "start_line": 8272, "end_line": 8278, "section": "Links" }, { "markdown": "[]\n\n[]: /uri\n", "html": "[]
\n[]: /uri
\n", "example": 551, "start_line": 8284, "end_line": 8291, "section": "Links" }, { "markdown": "[\n ]\n\n[\n ]: /uri\n", "html": "[\n]
\n[\n]: /uri
\n", "example": 552, "start_line": 8294, "end_line": 8305, "section": "Links" }, { "markdown": "[foo][]\n\n[foo]: /url \"title\"\n", "html": "\n", "example": 553, "start_line": 8317, "end_line": 8323, "section": "Links" }, { "markdown": "[*foo* bar][]\n\n[*foo* bar]: /url \"title\"\n", "html": "\n", "example": 554, "start_line": 8326, "end_line": 8332, "section": "Links" }, { "markdown": "[Foo][]\n\n[foo]: /url \"title\"\n", "html": "\n", "example": 555, "start_line": 8337, "end_line": 8343, "section": "Links" }, { "markdown": "[foo] \n[]\n\n[foo]: /url \"title\"\n", "html": "foo\n[]
\n", "example": 556, "start_line": 8350, "end_line": 8358, "section": "Links" }, { "markdown": "[foo]\n\n[foo]: /url \"title\"\n", "html": "\n", "example": 557, "start_line": 8370, "end_line": 8376, "section": "Links" }, { "markdown": "[*foo* bar]\n\n[*foo* bar]: /url \"title\"\n", "html": "\n", "example": 558, "start_line": 8379, "end_line": 8385, "section": "Links" }, { "markdown": "[[*foo* bar]]\n\n[*foo* bar]: /url \"title\"\n", "html": "[foo bar]
\n", "example": 559, "start_line": 8388, "end_line": 8394, "section": "Links" }, { "markdown": "[[bar [foo]\n\n[foo]: /url\n", "html": "[[bar foo
\n", "example": 560, "start_line": 8397, "end_line": 8403, "section": "Links" }, { "markdown": "[Foo]\n\n[foo]: /url \"title\"\n", "html": "\n", "example": 561, "start_line": 8408, "end_line": 8414, "section": "Links" }, { "markdown": "[foo] bar\n\n[foo]: /url\n", "html": "foo bar
\n", "example": 562, "start_line": 8419, "end_line": 8425, "section": "Links" }, { "markdown": "\\[foo]\n\n[foo]: /url \"title\"\n", "html": "[foo]
\n", "example": 563, "start_line": 8431, "end_line": 8437, "section": "Links" }, { "markdown": "[foo*]: /url\n\n*[foo*]\n", "html": "*foo*
\n", "example": 564, "start_line": 8443, "end_line": 8449, "section": "Links" }, { "markdown": "[foo][bar]\n\n[foo]: /url1\n[bar]: /url2\n", "html": "\n", "example": 565, "start_line": 8455, "end_line": 8462, "section": "Links" }, { "markdown": "[foo][]\n\n[foo]: /url1\n", "html": "\n", "example": 566, "start_line": 8464, "end_line": 8470, "section": "Links" }, { "markdown": "[foo]()\n\n[foo]: /url1\n", "html": "\n", "example": 567, "start_line": 8474, "end_line": 8480, "section": "Links" }, { "markdown": "[foo](not a link)\n\n[foo]: /url1\n", "html": "foo(not a link)
\n", "example": 568, "start_line": 8482, "end_line": 8488, "section": "Links" }, { "markdown": "[foo][bar][baz]\n\n[baz]: /url\n", "html": "[foo]bar
\n", "example": 569, "start_line": 8493, "end_line": 8499, "section": "Links" }, { "markdown": "[foo][bar][baz]\n\n[baz]: /url1\n[bar]: /url2\n", "html": "\n", "example": 570, "start_line": 8505, "end_line": 8512, "section": "Links" }, { "markdown": "[foo][bar][baz]\n\n[baz]: /url1\n[foo]: /url2\n", "html": "[foo]bar
\n", "example": 571, "start_line": 8518, "end_line": 8525, "section": "Links" }, { "markdown": "\n", "html": "My
\n[]
![[foo]]
\n[[foo]]: /url "title"
\n", "example": 590, "start_line": 8711, "end_line": 8718, "section": "Images" }, { "markdown": "![Foo]\n\n[foo]: /url \"title\"\n", "html": "![foo]
\n", "example": 592, "start_line": 8735, "end_line": 8741, "section": "Images" }, { "markdown": "\\![foo]\n\n[foo]: /url \"title\"\n", "html": "!foo
\n", "example": 593, "start_line": 8747, "end_line": 8753, "section": "Images" }, { "markdown": "https://foo.bar.baz/test?q=hello&id=22&boolean
\n", "example": 595, "start_line": 8787, "end_line": 8791, "section": "Autolinks" }, { "markdown": "<https://foo.bar/baz bim>
\n", "example": 602, "start_line": 8845, "end_line": 8849, "section": "Autolinks" }, { "markdown": "<foo+@bar.example.com>
\n", "example": 606, "start_line": 8892, "end_line": 8896, "section": "Autolinks" }, { "markdown": "<>\n", "html": "<>
\n", "example": 607, "start_line": 8901, "end_line": 8905, "section": "Autolinks" }, { "markdown": "< https://foo.bar >\n", "html": "< https://foo.bar >
\n", "example": 608, "start_line": 8908, "end_line": 8912, "section": "Autolinks" }, { "markdown": "<m:abc>
\n", "example": 609, "start_line": 8915, "end_line": 8919, "section": "Autolinks" }, { "markdown": "<foo.bar.baz>
\n", "example": 610, "start_line": 8922, "end_line": 8926, "section": "Autolinks" }, { "markdown": "https://example.com\n", "html": "https://example.com
\n", "example": 611, "start_line": 8929, "end_line": 8933, "section": "Autolinks" }, { "markdown": "foo@bar.example.com\n", "html": "foo@bar.example.com
\n", "example": 612, "start_line": 8936, "end_line": 8940, "section": "Autolinks" }, { "markdown": "Foo
<33> <__>
\n", "example": 618, "start_line": 9065, "end_line": 9069, "section": "Raw HTML" }, { "markdown": "\n", "html": "<a h*#ref="hi">
\n", "example": 619, "start_line": 9074, "end_line": 9078, "section": "Raw HTML" }, { "markdown": " \n", "html": "<a href="hi'> <a href=hi'>
\n", "example": 620, "start_line": 9083, "end_line": 9087, "section": "Raw HTML" }, { "markdown": "< a><\nfoo>< a><\nfoo><bar/ >\n<foo bar=baz\nbim!bop />
\n", "example": 621, "start_line": 9092, "end_line": 9102, "section": "Raw HTML" }, { "markdown": "\n", "html": "<a href='bar'title=title>
\n", "example": 622, "start_line": 9107, "end_line": 9111, "section": "Raw HTML" }, { "markdown": "</a href="foo">
\n", "example": 624, "start_line": 9125, "end_line": 9129, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "foo
\n", "example": 625, "start_line": 9134, "end_line": 9140, "section": "Raw HTML" }, { "markdown": "foo foo -->\n\nfoo foo -->\n", "html": "foo foo -->
\nfoo foo -->
\n", "example": 626, "start_line": 9142, "end_line": 9149, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "foo
\n", "example": 627, "start_line": 9154, "end_line": 9158, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "foo
\n", "example": 628, "start_line": 9163, "end_line": 9167, "section": "Raw HTML" }, { "markdown": "foo &<]]>\n", "html": "foo &<]]>
\n", "example": 629, "start_line": 9172, "end_line": 9176, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "\n", "example": 630, "start_line": 9182, "end_line": 9186, "section": "Raw HTML" }, { "markdown": "foo \n", "html": "\n", "example": 631, "start_line": 9191, "end_line": 9195, "section": "Raw HTML" }, { "markdown": "\n", "html": "<a href=""">
\n", "example": 632, "start_line": 9198, "end_line": 9202, "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": 644, "start_line": 9327, "end_line": 9331, "section": "Hard line breaks" }, { "markdown": "foo \n", "html": "foo
\n", "example": 645, "start_line": 9334, "end_line": 9338, "section": "Hard line breaks" }, { "markdown": "### foo\\\n", "html": "foo\nbaz
\n", "example": 648, "start_line": 9363, "end_line": 9369, "section": "Soft line breaks" }, { "markdown": "foo \n baz\n", "html": "foo\nbaz
\n", "example": 649, "start_line": 9375, "end_line": 9381, "section": "Soft line breaks" }, { "markdown": "hello $.;'there\n", "html": "hello $.;'there
\n", "example": 650, "start_line": 9395, "end_line": 9399, "section": "Textual content" }, { "markdown": "Foo χρῆν\n", "html": "Foo χρῆν
\n", "example": 651, "start_line": 9402, "end_line": 9406, "section": "Textual content" }, { "markdown": "Multiple spaces\n", "html": "Multiple spaces
\n", "example": 652, "start_line": 9411, "end_line": 9415, "section": "Textual content" } ] elvish-0.21.0/pkg/md/stack.go 0000664 0000000 0000000 00000000342 14657203754 0015715 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.21.0/pkg/md/testdata/ 0000775 0000000 0000000 00000000000 14657203754 0016073 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/ 0000775 0000000 0000000 00000000000 14657203754 0017071 5 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender/ 0000775 0000000 0000000 00000000000 14657203754 0024222 5 ustar 00root root 0000000 0000000 09165d96e6eede6b6057e300935fb1aa5c98243ed4f94b75a3ae31fb129e696d 0000664 0000000 0000000 00000000044 14657203754 0034767 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("[](<>(0))") 0d768707e28d09f6ef49b5e8c7e8f44fbea157f6296a05abb302ee5b77e3a168 0000664 0000000 0000000 00000000040 14657203754 0035056 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("<&@0>") 17c530b620d9fbcf8870fe975ed11dd7062eb582d7ca3fc5ddbc293d5344e2f9 0000664 0000000 0000000 00000000115 14657203754 0035201 0 ustar 00root root 0000000 0000000 elvish-0.21.0/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 14657203754 0034642 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("\\ \n0") 2ced69587e727c3c37b87201e0e44e450090e6a195425a8fe0dc66bbc5163fd6 0000664 0000000 0000000 00000000042 14657203754 0034612 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("[]( <0)") 2ec9c489ff1c9ed8d8a24c2eb9e4033303149dcd9875ea82bf4764d0df144af5 0000664 0000000 0000000 00000000043 14657203754 0035132 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("* ```\n0") 334ff8e8eaece3f84601f65db41d9cffa5634be1d23c4f97ea44edf4669f712f 0000664 0000000 0000000 00000000043 14657203754 0035361 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string(" ***") 3463752fb4670a5a72d0033d9f2caee37023353a019825bb48223e7a01d9b760 0000664 0000000 0000000 00000000043 14657203754 0034253 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("_0 _") 4310d034c2ad018a0649d15c7d6748bfad6779fb067b28b09debb146eb77bd31 0000664 0000000 0000000 00000000043 14657203754 0034732 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("*\n+ ***") 43d96cdf434f4f13cd32c391724d7c1cbc55d63d92195ea9da67c397abb722fa 0000664 0000000 0000000 00000000041 14657203754 0035111 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string(">\\---") 45f9492476d79ae796da2ffeb3476370586553a5071b32bd1cc7a07fbd80356f 0000664 0000000 0000000 00000000041 14657203754 0034551 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("* --") 46d002372d39c68359423d57dbe63c3c83003ecbd5e601286a5f80bc691624ef 0000664 0000000 0000000 00000000042 14657203754 0034442 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("_00*0*_") 4951856280eb053ef3a943dcf6fb1e27cd595e4ab8aa00934517a702e71ca940 0000664 0000000 0000000 00000000042 14657203754 0034576 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("- - - *") 49ef19e3514a8f95ff404e89d70d308b76d947b505a9593e2e72712048f2ae42 0000664 0000000 0000000 00000000044 14657203754 0034425 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("*\n ") 51e230c835df97b3aec428fe7a0be1704811421c5c28a78c9c3b74983157b1e4 0000664 0000000 0000000 00000000044 14657203754 0034524 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("*`0`*") 594ea6c06082c3fc565a1304ce5a809d2a37dac682163c23c73be7748bc5b464 0000664 0000000 0000000 00000000052 14657203754 0034575 0 ustar 00root root 0000000 0000000 elvish-0.21.0/pkg/md/testdata/fuzz/FuzzFmtPreservesHTMLRender go test fuzz v1 string("*0\n ***\n0*") 5e0c9718d6e600b573a921052b15c44fb57a68a9d70af7a7c8899b33adefcc40 0000664 0000000 0000000 00000000052 14657203754 0034752 0 ustar 00root root 0000000 0000000 elvish-0.21.0/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