Compare commits

...

10 commits

1311 changed files with 402922 additions and 118 deletions

View file

@ -41,7 +41,7 @@ jobs:
cache: true
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y dpkg-dev
run: sudo apt-get update && sudo apt-get install -y dpkg-dev rpm
- name: Derive release version
shell: bash
@ -61,6 +61,10 @@ jobs:
shell: bash
run: ./scripts/build-deb.sh "${VERSION}"
- name: Build rpm package
shell: bash
run: ./scripts/build-rpm.sh "${VERSION}"
- name: Bundle tarball
shell: bash
run: |
@ -74,6 +78,7 @@ jobs:
sha256sum \
"${BIN_NAME}-${GITHUB_REF_NAME}-x86_64-unknown-linux-gnu.tar.gz" \
"target/debian/${BIN_NAME}_${VERSION}_amd64.deb" \
"target/rpm/${BIN_NAME}-${VERSION}-1.x86_64.rpm" \
> "${BIN_NAME}-${GITHUB_REF_NAME}-checksums.txt"
- name: Publish release assets
@ -84,3 +89,4 @@ jobs:
vcom-${{ github.ref_name }}-x86_64-unknown-linux-gnu.tar.gz
vcom-${{ github.ref_name }}-checksums.txt
target/debian/vcom_${{ env.VERSION }}_amd64.deb
target/rpm/vcom-${{ env.VERSION }}-1.x86_64.rpm

View file

@ -1,5 +1,5 @@
pkgname=vcom
pkgver=0.2.4
pkgver=0.2.6
pkgrel=1
pkgdesc="Terminal file manager inspired by Midnight Commander"
arch=("x86_64" "aarch64")

View file

@ -15,6 +15,14 @@
![vcom screenshot 2](docs/screen2.png)
![vcom screenshot 3](docs/screen3.png)
## Quick install
```bash
curl -fsSL https://raw.githubusercontent.com/vrubelroman/vcom/main/scripts/install.sh | bash
```
This single command installs vcom with Nerd Font and image preview support on any Linux distribution.
## Build and run
Run directly:
@ -36,13 +44,13 @@ go build -o vcom ./cmd/vcom
Run directly from the flake:
```bash
nix run github:vrubelroman/vcom?ref=v0.2.4
nix run github:vrubelroman/vcom?ref=v0.2.6
```
Install into user profile:
```bash
nix profile add github:vrubelroman/vcom?ref=v0.2.4
nix profile add github:vrubelroman/vcom?ref=v0.2.6
```
The Nix package wraps `vcom` with `ueberzugpp` in `PATH`, so image preview works in non-`kitty` terminals out of the box.
@ -52,11 +60,15 @@ The Nix package wraps `vcom` with `ueberzugpp` in `PATH`, so image preview works
Download and install the latest release:
```bash
curl -sL https://github.com/vrubelroman/vcom/releases/download/v0.2.4/vcom_0.2.4_amd64.deb -o /tmp/vcom_0.2.4_amd64.deb
sudo apt install /tmp/vcom_0.2.4_amd64.deb
curl -sL https://github.com/vrubelroman/vcom/releases/download/v0.2.6/vcom_0.2.6_amd64.deb -o /tmp/vcom_0.2.6_amd64.deb
sudo apt install /tmp/vcom_0.2.6_amd64.deb
```
The Debian package declares `ueberzug` (or `ueberzugpp` where available) as a dependency for image preview outside `kitty`.
The Debian package recommends `ueberzugpp` for image preview outside `kitty` (optional). To install it:
```bash
sudo apt install pipx && pipx ensurepath && pipx install ueberzugpp
```
### Arch Linux
@ -68,6 +80,27 @@ makepkg -si
The Arch package depends on `ueberzugpp`, so non-`kitty` image preview is installed together with `vcom`.
### Fedora / RHEL
Download and install the latest RPM:
```bash
curl -sL https://github.com/vrubelroman/vcom/releases/download/v0.2.6/vcom-0.2.6-1.x86_64.rpm -o /tmp/vcom.rpm
sudo dnf install /tmp/vcom.rpm
```
Or on RHEL/CentOS:
```bash
sudo rpm -ivh /tmp/vcom.rpm
```
The RPM package recommends `ueberzugpp` for image preview outside `kitty` (optional):
```bash
sudo dnf install ueberzugpp
```
## Font requirement (icons)
For file icons, `vcom` expects a Nerd Font in your terminal profile.
@ -183,19 +216,21 @@ Built-in themes (press `t` to open theme selector or set `ui.theme` in config):
## Releases
Pushing a tag like `v0.2.4` triggers GitHub Actions release workflow (`.github/workflows/release.yml`) which:
Pushing a tag like `v0.2.6` triggers GitHub Actions release workflow (`.github/workflows/release.yml`) which:
- runs tests
- vendors Go modules
- builds release binary
- builds Debian package
- builds RPM package
- publishes release assets
Release artifacts:
- `vcom-v0.2.4-x86_64-unknown-linux-gnu.tar.gz`
- `vcom_0.2.4_amd64.deb`
- `vcom-v0.2.4-checksums.txt`
- `vcom-v0.2.6-x86_64-unknown-linux-gnu.tar.gz`
- `vcom_0.2.6_amd64.deb`
- `vcom-0.2.6-1.x86_64.rpm`
- `vcom-v0.2.6-checksums.txt`
## Notes

View file

@ -13,7 +13,7 @@
lib = pkgs.lib;
packageBase = pkgs.buildGoModule {
pname = "vcom";
version = "0.2.4";
version = "0.2.6";
src = ./.;
vendorHash = null;

View file

@ -70,8 +70,6 @@ type BehaviorConfig struct {
ConfirmOverwrite bool `toml:"confirm_overwrite"`
CalculateDirSizeOnSpace bool `toml:"calculate_dir_size_on_space"`
FollowSymlinks bool `toml:"follow_symlinks"`
AutoRefresh bool `toml:"auto_refresh"`
AutoRefreshInterval int `toml:"auto_refresh_interval"`
}
func Default() Config {
@ -113,8 +111,6 @@ func Default() Config {
ConfirmOverwrite: true,
CalculateDirSizeOnSpace: true,
FollowSymlinks: false,
AutoRefresh: true,
AutoRefreshInterval: 5,
},
}
}
@ -209,14 +205,6 @@ func (c *Config) Validate() error {
if c.UI.CenterWidthPercent < 20 || c.UI.CenterWidthPercent > 60 {
return errors.New("ui.center_width_percent must be between 20 and 60")
}
if c.Behavior.AutoRefresh {
if c.Behavior.AutoRefreshInterval < 1 {
c.Behavior.AutoRefreshInterval = 5
}
if c.Behavior.AutoRefreshInterval > 60 {
return errors.New("behavior.auto_refresh_interval must be between 1 and 60")
}
}
switch strings.ToLower(strings.TrimSpace(c.Browser.Sort.By)) {
case "", "name":
c.Browser.Sort.By = "name"

View file

@ -133,7 +133,7 @@ func BuildPreview(entry Entry, options PreviewOptions) Preview {
}
data := buffer.Bytes()
if format, dimensions, ok := detectImage(data); ok {
if format, dimensions, ok := DetectImage(data); ok {
preview.Kind = PreviewKindImage
preview.Metadata.ImageFormat = format
preview.Metadata.ImageSize = dimensions
@ -671,7 +671,7 @@ func buildVideoPreview(entry Entry, options PreviewOptions, base Preview) Previe
return base
}
func detectImage(data []byte) (string, string, bool) {
func DetectImage(data []byte) (string, string, bool) {
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return "", "", false

View file

@ -485,6 +485,11 @@ func (c *SSHClient) CopyFileFromRemote(remotePath, localPath string) error {
return nil
}
// DownloadFile downloads a remote file to a local path via SFTP.
func (c *SSHClient) DownloadFile(remotePath, localPath string) error {
return c.CopyFileFromRemote(remotePath, localPath)
}
// CopyDirToRemote recursively copies a local directory to a remote path.
func (c *SSHClient) CopyDirToRemote(localDir, remoteDir string) error {
return c.copyDirToRemote(localDir, remoteDir, nil, nil)

View file

@ -27,7 +27,7 @@ import (
"vcom/internal/theme"
)
const version = "v0.2.4"
const version = "v0.2.6"
type modalKind int
@ -91,8 +91,9 @@ type themeSelectorState struct {
}
type previewMsg struct {
entryPath string
preview vfs.Preview
entryPath string
preview vfs.Preview
remoteImageTemp string // temp file path for downloaded remote image, cleared on change
}
type dirSizeMsg struct {
@ -167,7 +168,6 @@ type copyDoneMsg struct {
type dismissNoticeMsg struct{}
type dismissYankFlashMsg struct{}
type tickMsg struct{}
type externalOpenMsg struct {
path string
err error
@ -260,9 +260,10 @@ type Model struct {
archiveProgress chan tea.Msg
archiveFormat string
deleteKind string // "trash" or "permanent" — selected in delete modal
ssh *sshState
preSSHPath string // original path before entering SSH mode
themeSelector *themeSelectorState // nil when not in theme selector dialog
ssh *sshState
preSSHPath string // original path before entering SSH mode
themeSelector *themeSelectorState // nil when not in theme selector dialog
remoteImageTemp string // temp file of downloaded remote image, cleaned on change/quit
}
func NewModel(cfg config.Config, configPath string) (Model, error) {
@ -334,9 +335,6 @@ func NewModel(cfg config.Config, configPath string) (Model, error) {
}
func (m Model) Init() tea.Cmd {
if m.cfg.Behavior.AutoRefresh {
return tea.Batch(m.loadPreviewCmd(), autoRefreshTickCmd(m.cfg.Behavior.AutoRefreshInterval))
}
return m.loadPreviewCmd()
}
@ -354,6 +352,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
log.Printf("[EVENT] previewMsg: path=%s kind=%s", msg.entryPath, msg.preview.Kind)
if selected, ok := m.activePane().Selected(); ok && selected.Path == msg.entryPath {
m.applyPreview(msg.preview)
// Track remote image temp file for cleanup
if msg.remoteImageTemp != "" {
if m.remoteImageTemp != "" && m.remoteImageTemp != msg.remoteImageTemp {
os.Remove(m.remoteImageTemp)
}
m.remoteImageTemp = msg.remoteImageTemp
} else if msg.preview.Kind != vfs.PreviewKindImage && m.remoteImageTemp != "" {
os.Remove(m.remoteImageTemp)
m.remoteImageTemp = ""
}
}
if m.selectMode && !m.viewMode && msg.preview.Kind != vfs.PreviewKindText {
m.selectMode = false
@ -923,20 +931,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.syncPreviewContent()
return m, nil
case tickMsg:
if !m.cfg.Behavior.AutoRefresh ||
m.busy ||
m.copyJob != nil ||
m.archiveJob != nil ||
m.modal.kind != modalNone ||
m.filterMode ||
m.cursorMode || m.visualMode ||
m.viewMode {
return m, autoRefreshTickCmd(m.cfg.Behavior.AutoRefreshInterval)
}
m.autoRefreshPanes()
return m, tea.Batch(autoRefreshTickCmd(m.cfg.Behavior.AutoRefreshInterval), m.loadPreviewCmd())
case externalOpenMsg:
if msg.err != nil {
m.status = fmt.Sprintf("Open failed: %v", msg.err)
@ -1110,7 +1104,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.filterMode = false
m.status = "Filter cleared"
return m, nil
case key.Matches(msg, m.keys.Confirm):
case msg.String() == "enter":
log.Printf("[KEY] Filter: Enter — query=%s", m.filterQuery)
m.filterMode = false
m.filterInput.Blur()
@ -1162,6 +1156,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.saveSession()
m.cleanupArchiveMounts()
m.cleanupImageOverlay()
m.cleanupRemoteImageTemp()
return m, tea.Quit
case key.Matches(msg, m.keys.Help):
log.Printf("[KEY] Help — open help modal")
@ -1409,7 +1404,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.modal = modalState{}
m.status = "Cancelled"
return m, nil
case key.Matches(msg, m.keys.Confirm):
case msg.String() == "enter":
value := strings.TrimSpace(m.modal.input.Value())
if value == "" {
if m.modal.kind == modalMkdir {
@ -1608,7 +1603,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
m.status = "Cancelled"
return m, nil
case key.Matches(msg, m.keys.Confirm):
case msg.String() == "enter":
// If a test is already in progress, ignore
if m.ssh != nil && m.ssh.testingConn {
return m, nil
@ -1773,35 +1768,32 @@ func (m *Model) reloadPane(id PaneID, preserve string) error {
}
func (m *Model) refreshAllPanes(status string) (tea.Model, tea.Cmd) {
leftSelected := selectedName(&m.left)
rightSelected := selectedName(&m.right)
log.Printf("[PANEL] refreshAllPanes: left=%s right=%s", leftSelected, rightSelected)
if err := m.reloadPane(PaneLeft, leftSelected); err != nil {
m.status = err.Error()
return m, nil
}
if err := m.reloadPane(PaneRight, rightSelected); err != nil {
m.status = err.Error()
return m, nil
log.Printf("[PANEL] refreshAllPanes")
for _, id := range []PaneID{PaneLeft, PaneRight} {
pane := m.paneByID(id)
if name := selectedName(pane); name != "" {
pane.SaveCursor(pane.Path, name)
}
preserve := pane.LoadCursor(pane.Path)
var err error
if pane.InRemote() {
err = m.reloadRemotePane(id, preserve)
} else if pane.InArchive() {
err = m.reloadPane(id, preserve)
} else {
err = m.reloadPane(id, preserve)
}
if err != nil {
log.Printf("[REFRESH] pane=%s path=%s err=%v", id, pane.Path, err)
m.status = err.Error()
return m, nil
}
}
m.status = status
return m, m.loadPreviewCmd()
}
func (m *Model) autoRefreshPanes() {
for _, id := range []PaneID{PaneLeft, PaneRight} {
pane := m.paneByID(id)
if pane.InRemote() || pane.InArchive() {
continue
}
if name := selectedName(pane); name != "" {
pane.SaveCursor(pane.Path, name)
}
if err := m.reloadPane(id, pane.LoadCursor(pane.Path)); err != nil {
log.Printf("[REFRESH] pane=%s path=%s err=%v", id, pane.Path, err)
}
}
}
func (m *Model) moveCursor(delta int) {
// When a filter query is active on this pane, move through filtered entries
@ -2237,7 +2229,8 @@ func (m Model) loadPreviewCmd() tea.Cmd {
}
}
// Remote preview: read file via SFTP and build a text preview
// Remote preview: read file via SFTP, detect kind, build preview.
// Images are downloaded to a local temp file so the overlay (ueberzugpp/kitty) can read them.
if mount, ok := m.activePane().CurrentRemote(); ok {
return func() tea.Msg {
rc, err := mount.Client.ReadFile(selected.Path)
@ -2253,8 +2246,8 @@ func (m Model) loadPreviewCmd() tea.Cmd {
}
defer rc.Close()
maxBytes := int64(m.cfg.Preview.MaxPreviewBytes)
limited := io.LimitReader(rc, maxBytes)
raw, readErr := io.ReadAll(limited)
sample := io.LimitReader(rc, maxBytes)
raw, readErr := io.ReadAll(sample)
if readErr != nil {
return previewMsg{
entryPath: selected.Path,
@ -2265,6 +2258,66 @@ func (m Model) loadPreviewCmd() tea.Cmd {
},
}
}
meta := vfs.Metadata{
Path: selected.Path,
Size: selected.Size,
SizeKnown: true,
Extension: selected.Extension,
Permissions: vfs.Permissions(selected.Mode),
ModifiedAt: vfs.ShortTime(selected.ModifiedAt),
}
// Detect image by magic bytes
if format, dims, isImage := vfs.DetectImage(raw); isImage {
// Download full image to temp
tmpDir := filepath.Join(os.TempDir(), "vcom-remote-images")
os.MkdirAll(tmpDir, 0o700)
tmpFile, tmpErr := os.CreateTemp(tmpDir, "vcom-img-*"+filepath.Ext(selected.Path))
if tmpErr == nil {
tmpPath := tmpFile.Name()
tmpFile.Close()
if dlErr := mount.Client.DownloadFile(selected.Path, tmpPath); dlErr == nil {
meta.Path = tmpPath
return previewMsg{
entryPath: selected.Path,
remoteImageTemp: tmpPath,
preview: vfs.Preview{
Kind: vfs.PreviewKindImage,
Title: selected.DisplayName(),
Body: fmt.Sprintf("%s (%s)\n%s", format, dims, vfs.HumanSize(selected.Size)),
Metadata: meta,
},
}
}
os.Remove(tmpPath)
}
// Download failed — show as image with metadata only
return previewMsg{
entryPath: selected.Path,
preview: vfs.Preview{
Kind: vfs.PreviewKindImage,
Title: selected.DisplayName(),
Body: fmt.Sprintf("%s (%s)\n(remote — could not download for overlay)", format, dims),
Metadata: meta,
},
}
}
// Detect binary (non-image)
if vfs.IsBinarySample(raw) {
return previewMsg{
entryPath: selected.Path,
preview: vfs.Preview{
Kind: vfs.PreviewKindBinary,
Title: selected.DisplayName(),
Body: fmt.Sprintf("Binary file\n%s • %s", vfs.HumanSize(selected.Size), selected.Extension),
Metadata: meta,
},
}
}
// Text preview
body := string(raw)
if int64(len(raw)) >= maxBytes {
body += "\n\n[... truncated ...]"
@ -2276,14 +2329,7 @@ func (m Model) loadPreviewCmd() tea.Cmd {
Title: selected.DisplayName(),
Body: body,
PlainBody: body,
Metadata: vfs.Metadata{
Path: selected.Path,
Size: selected.Size,
SizeKnown: true,
Extension: selected.Extension,
Permissions: vfs.Permissions(selected.Mode),
ModifiedAt: vfs.ShortTime(selected.ModifiedAt),
},
Metadata: meta,
},
}
}
@ -2358,17 +2404,20 @@ func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) {
kind, m.active, srcPane.Path, dstPane.Path, srcHasRemote, dstHasRemote, sources)
// Check for existing targets (fast — one Stat per top-level item)
// Skip when target is remote — local fs check doesn't apply.
existingTargets := 0
for _, sourcePath := range sources {
targetPath := filepath.Join(targetDir, filepath.Base(sourcePath))
exists, err := vfs.PathExists(targetPath)
if err != nil {
log.Printf("[ERROR] Transfer: path check failed: %s err=%v", targetPath, err)
m.status = err.Error()
return m, nil
}
if exists {
existingTargets++
if !dstHasRemote {
for _, sourcePath := range sources {
targetPath := filepath.Join(targetDir, filepath.Base(sourcePath))
exists, err := vfs.PathExists(targetPath)
if err != nil {
log.Printf("[ERROR] Transfer: path check failed: %s err=%v", targetPath, err)
m.status = err.Error()
return m, nil
}
if exists {
existingTargets++
}
}
}
overwrite := existingTargets > 0
@ -4352,12 +4401,7 @@ func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int)
}
lines = append(lines, barAndPct...)
}
bgStyle := lipgloss.NewStyle().Foreground(palette.Accent).Background(palette.Panel)
cancelStyle := lipgloss.NewStyle().Foreground(palette.CancelButton).Background(palette.Panel)
bgAndCancel := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(
bgStyle.Render("Background / b") + " " + cancelStyle.Render("Cancel / c"),
)
lines = append(lines, spacer, bgAndCancel)
lines = append(lines, spacer, renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle))
if job.background {
lines = append(lines, mutedStyle.Render("Transfer continues in background"))
}
@ -4436,13 +4480,7 @@ func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, widt
)
}
lines = append(lines,
spacer,
lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(
lipgloss.NewStyle().Foreground(palette.Info).Background(palette.Panel).Render("Background / b")+" "+
lipgloss.NewStyle().Foreground(palette.CancelButton).Background(palette.Panel).Render("Cancel / c"),
),
)
lines = append(lines, spacer, renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle))
if job.background {
switch job.kind {
case "extract":
@ -4933,12 +4971,6 @@ func dismissYankFlashCmd(delay time.Duration) tea.Cmd {
})
}
func autoRefreshTickCmd(seconds int) tea.Cmd {
return tea.Tick(time.Duration(seconds)*time.Second, func(time.Time) tea.Msg {
return tickMsg{}
})
}
func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir string, overwrite bool, stats vfs.TransferStats) tea.Cmd {
m.nextCopyJob++
jobID := m.nextCopyJob
@ -5819,6 +5851,13 @@ func (m *Model) cleanupImageOverlay() {
m.overlay.stop()
}
func (m *Model) cleanupRemoteImageTemp() {
if m.remoteImageTemp != "" {
os.Remove(m.remoteImageTemp)
m.remoteImageTemp = ""
}
}
func (m *Model) hoverIndexFor(pane PaneID) int {
if m.hover.ok && m.hover.pane == pane {
return m.hover.index

View file

@ -29,7 +29,7 @@ Section: utils
Priority: optional
Architecture: amd64
Maintainer: Roman Vrubel <roman@vrubel.dev>
Depends: ueberzug | ueberzugpp
Recommends: ueberzugpp
Description: Terminal file manager inspired by Midnight Commander
A two-pane terminal file manager with inspect mode and text previews.
EOF

62
scripts/build-rpm.sh Executable file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: $0 <version>" >&2
exit 1
fi
version="$1"
pkgname="vcom"
outdir="target/rpm"
buildroot="${outdir}/BUILDROOT"
rpmbuild_dir="${outdir}/rpmbuild"
rm -rf "$outdir"
mkdir -p \
"${buildroot}/usr/bin" \
"${buildroot}/usr/share/doc/vcom" \
"${buildroot}/usr/share/licenses/vcom" \
"${rpmbuild_dir}"/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
install -Dm755 "target/release/vcom" "${buildroot}/usr/bin/vcom"
install -Dm644 "README.md" "${buildroot}/usr/share/doc/vcom/README.md"
install -Dm644 "vcom.toml" "${buildroot}/usr/share/doc/vcom/vcom.toml"
install -Dm644 "LICENSE" "${buildroot}/usr/share/licenses/vcom/LICENSE"
cat > "${rpmbuild_dir}/SPECS/vcom.spec" <<EOF
Name: vcom
Version: ${version}
Release: 1%{?dist}
Summary: Terminal file manager inspired by Midnight Commander
License: GPLv3
URL: https://github.com/vrubelroman/vcom
BuildArch: x86_64
Recommends: ueberzugpp
%description
A two-pane terminal file manager with inspect mode and text previews.
%files
%dir /usr/share/doc/vcom
%dir /usr/share/licenses/vcom
/usr/bin/vcom
/usr/share/doc/vcom/README.md
/usr/share/doc/vcom/vcom.toml
/usr/share/licenses/vcom/LICENSE
%changelog
EOF
rpmbuild \
--define "_topdir $(realpath "${rpmbuild_dir}")" \
--define "_buildroot ${buildroot}" \
--buildroot "$(realpath "${buildroot}")" \
-bb \
--noclean \
"${rpmbuild_dir}/SPECS/vcom.spec"
rpm_path=$(find "${rpmbuild_dir}/RPMS" -name "*.rpm" | head -1)
cp "$rpm_path" "${outdir}/${pkgname}-${version}-1.x86_64.rpm"
echo "Built: ${outdir}/${pkgname}-${version}-1.x86_64.rpm"

192
scripts/install.sh Executable file
View file

@ -0,0 +1,192 @@
#!/usr/bin/env bash
set -euo pipefail
REPO="vrubelroman/vcom"
FONT_DIR="$HOME/.local/share/fonts/JetBrainsMonoNerd"
FONT_URL="https://github.com/ryanoasis/nerd-fonts/releases/latest/download/JetBrainsMono.zip"
BOLD="\033[1m"
GREEN="\033[32m"
YELLOW="\033[33m"
RESET="\033[0m"
info() { echo -e "${GREEN}${RESET} $*"; }
warn() { echo -e "${YELLOW}${RESET} $*"; }
header() { echo -e "\n${BOLD}== $* ==${RESET}"; }
# ---------- detect distro ----------
detect_distro() {
if [ -f /etc/os-release ]; then
. /etc/os-release
echo "${ID}"
elif command -v nixos-version >/dev/null 2>&1; then
echo "nixos"
else
echo "unknown"
fi
}
# ---------- latest release tag ----------
latest_tag() {
curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" 2>/dev/null \
| grep '"tag_name":' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/'
}
# ---------- nerd font ----------
install_nerd_font() {
header "Nerd Font"
if fc-list 2>/dev/null | grep -qi "JetBrainsMono.*Nerd"; then
info "JetBrainsMono Nerd Font already installed"
return
fi
DISTRO=$(detect_distro)
case "$DISTRO" in
arch|manjaro|endeavouros)
info "Installing via pacman..."
sudo pacman -S --noconfirm ttf-jetbrains-mono-nerd 2>/dev/null || true
;;
nixos|nix)
if command -v nix >/dev/null 2>&1; then
info "Installing via nix profile..."
nix profile install nixpkgs#nerd-fonts.jetbrains-mono 2>/dev/null || true
fi
;;
esac
if fc-list 2>/dev/null | grep -qi "JetBrainsMono.*Nerd"; then
info "JetBrainsMono Nerd Font installed via package manager"
return
fi
info "Downloading JetBrainsMono Nerd Font..."
mkdir -p "$FONT_DIR"
curl -fsSL "$FONT_URL" -o /tmp/JetBrainsMono.zip
unzip -oq /tmp/JetBrainsMono.zip -d "$FONT_DIR"
rm -f /tmp/JetBrainsMono.zip
if command -v fc-cache >/dev/null 2>&1; then
fc-cache -fv "$FONT_DIR" 2>/dev/null || true
fi
info "Nerd Font installed to $FONT_DIR"
}
# ---------- ueberzugpp ----------
install_ueberzugpp() {
header "ueberzugpp (image preview)"
if command -v ueberzugpp >/dev/null 2>&1; then
info "ueberzugpp already installed"
return
fi
DISTRO=$(detect_distro)
case "$DISTRO" in
arch|manjaro|endeavouros)
sudo pacman -S --noconfirm ueberzugpp 2>/dev/null && return || true
;;
fedora|rhel|centos|almalinux|rocky)
sudo dnf install -y ueberzugpp 2>/dev/null && return || true
;;
nixos|nix)
info "ueberzugpp is bundled with vcom on Nix — skipping"
return
;;
esac
if ! command -v pipx >/dev/null 2>&1; then
info "Installing pipx..."
sudo apt-get update -qq && sudo apt-get install -y -qq pipx 2>/dev/null || \
sudo dnf install -y pipx 2>/dev/null || \
sudo zypper install -y pipx 2>/dev/null || true
pipx ensurepath 2>/dev/null || true
fi
if command -v pipx >/dev/null 2>&1; then
pipx install ueberzugpp 2>/dev/null && return || true
fi
warn "Could not install ueberzugpp — image preview may not work outside Kitty"
}
# ---------- vcom ----------
install_vcom() {
header "vcom"
TAG=$(latest_tag)
if [ -z "$TAG" ]; then
warn "Could not determine latest version from GitHub"
return 1
fi
VER="${TAG#v}"
DISTRO=$(detect_distro)
case "$DISTRO" in
debian|ubuntu|linuxmint|pop|elementary|zorin|kali|raspbian)
DEB="vcom_${VER}_amd64.deb"
URL="https://github.com/${REPO}/releases/download/${TAG}/${DEB}"
info "Downloading $DEB for $DISTRO..."
curl -fsSL "$URL" -o "/tmp/${DEB}"
sudo apt install -y "/tmp/${DEB}"
rm -f "/tmp/${DEB}"
;;
fedora|rhel|centos|almalinux|rocky)
RPM="vcom-${VER}-1.x86_64.rpm"
URL="https://github.com/${REPO}/releases/download/${TAG}/${RPM}"
info "Downloading $RPM for $DISTRO..."
curl -fsSL "$URL" -o "/tmp/${RPM}"
sudo dnf install -y "/tmp/${RPM}" 2>/dev/null || \
sudo rpm -ivh "/tmp/${RPM}"
rm -f "/tmp/${RPM}"
;;
opensuse*|sles)
RPM="vcom-${VER}-1.x86_64.rpm"
URL="https://github.com/${REPO}/releases/download/${TAG}/${RPM}"
info "Downloading $RPM for $DISTRO..."
curl -fsSL "$URL" -o "/tmp/${RPM}"
sudo zypper install -y "/tmp/${RPM}"
rm -f "/tmp/${RPM}"
;;
arch|manjaro|endeavouros)
TAR="vcom-${TAG}-x86_64-unknown-linux-gnu.tar.gz"
URL="https://github.com/${REPO}/releases/download/${TAG}/${TAR}"
info "Downloading $TAR for $DISTRO..."
curl -fsSL "$URL" -o "/tmp/${TAR}"
mkdir -p "$HOME/.local/bin"
tar -xzf "/tmp/${TAR}" -C "$HOME/.local/bin"
rm -f "/tmp/${TAR}"
if ! echo "$PATH" | grep -q "$HOME/.local/bin"; then
warn "Add ~/.local/bin to your PATH:"
echo ' export PATH="$HOME/.local/bin:$PATH"'
fi
;;
nixos|nix)
info "Installing via nix profile..."
nix profile add "github:${REPO}?ref=${TAG}"
return
;;
*)
TAR="vcom-${TAG}-x86_64-unknown-linux-gnu.tar.gz"
URL="https://github.com/${REPO}/releases/download/${TAG}/${TAR}"
info "Unknown distro ($DISTRO) — installing binary tarball"
curl -fsSL "$URL" -o "/tmp/${TAR}"
mkdir -p "$HOME/.local/bin"
tar -xzf "/tmp/${TAR}" -C "$HOME/.local/bin"
rm -f "/tmp/${TAR}"
warn "Add ~/.local/bin to your PATH if not already:"
echo ' export PATH="$HOME/.local/bin:$PATH"'
;;
esac
info "vcom ${TAG} installed successfully"
}
# ---------- main ----------
echo ""
echo -e "${BOLD}vcom installer${RESET}"
echo ""
if ! command -v curl >/dev/null 2>&1; then
echo "error: curl is required" >&2
exit 1
fi
if ! command -v unzip >/dev/null 2>&1; then
echo "error: unzip is required" >&2
exit 1
fi
install_nerd_font
install_ueberzugpp
install_vcom
echo ""
info "Done! Run: vcom"

1
src/vcom-0.2.5.tar.gz Symbolic link
View file

@ -0,0 +1 @@
/home/vrubel/projects/vcom/vcom-0.2.5.tar.gz

0
src/vcom-0.2.5/.codex Normal file
View file

View file

@ -0,0 +1,86 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
env:
BIN_NAME: vcom
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Run tests
run: GOFLAGS=-mod=vendor go test ./...
release:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y dpkg-dev
- name: Derive release version
shell: bash
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
- name: Vendor Go modules
run: go mod vendor
- name: Build release binary
shell: bash
run: |
set -euo pipefail
mkdir -p target/release
GOFLAGS=-mod=vendor CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "target/release/${BIN_NAME}" ./cmd/vcom
- name: Build deb package
shell: bash
run: ./scripts/build-deb.sh "${VERSION}"
- name: Bundle tarball
shell: bash
run: |
set -euo pipefail
tar -C target/release -czf "${BIN_NAME}-${GITHUB_REF_NAME}-x86_64-unknown-linux-gnu.tar.gz" "${BIN_NAME}"
- name: Generate checksums
shell: bash
run: |
set -euo pipefail
sha256sum \
"${BIN_NAME}-${GITHUB_REF_NAME}-x86_64-unknown-linux-gnu.tar.gz" \
"target/debian/${BIN_NAME}_${VERSION}_amd64.deb" \
> "${BIN_NAME}-${GITHUB_REF_NAME}-checksums.txt"
- name: Publish release assets
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: |
vcom-${{ github.ref_name }}-x86_64-unknown-linux-gnu.tar.gz
vcom-${{ github.ref_name }}-checksums.txt
target/debian/vcom_${{ env.VERSION }}_amd64.deb

2
src/vcom-0.2.5/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
target/
vcom

674
src/vcom-0.2.5/LICENSE Normal file
View file

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

34
src/vcom-0.2.5/PKGBUILD Normal file
View file

@ -0,0 +1,34 @@
pkgname=vcom
pkgver=0.2.5
pkgrel=1
pkgdesc="Terminal file manager inspired by Midnight Commander"
arch=("x86_64" "aarch64")
url="https://github.com/vrubelroman/vcom"
license=("MIT")
depends=("glibc" "ueberzugpp")
makedepends=("go")
source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz")
sha256sums=("SKIP")
build() {
cd "$srcdir/$pkgname-$pkgver"
export CGO_ENABLED=0
export GOFLAGS="-mod=vendor -trimpath"
go build -ldflags="-s -w" -o "target/release/vcom" ./cmd/vcom
}
check() {
cd "$srcdir/$pkgname-$pkgver"
export CGO_ENABLED=0
export GOFLAGS="-mod=vendor"
go test ./...
}
package() {
cd "$srcdir/$pkgname-$pkgver"
install -Dm755 "target/release/vcom" "$pkgdir/usr/bin/vcom"
install -Dm644 "README.md" "$pkgdir/usr/share/doc/vcom/README.md"
install -Dm644 "vcom.toml" "$pkgdir/usr/share/doc/vcom/vcom.toml"
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/vcom/LICENSE"
}

204
src/vcom-0.2.5/README.md Normal file
View file

@ -0,0 +1,204 @@
# vcom
`vcom` is a two-pane terminal file manager with a fast built-in info/preview panel for the active selection.
## Why vcom
- Two-pane workflow focused on keyboard speed
- Built-in preview/info pane for the active selection
- Asynchronous copy/move with progress and background mode
- Theme support and configurable layout/columns
## Screenshots
![vcom screenshot](docs/screen.png)
![vcom screenshot 2](docs/screen2.png)
![vcom screenshot 3](docs/screen3.png)
## Build and run
Run directly:
```bash
go run ./cmd/vcom
```
Build local binary:
```bash
go build -o vcom ./cmd/vcom
```
## Installation
### NixOS / Nix
Run directly from the flake:
```bash
nix run github:vrubelroman/vcom?ref=v0.2.5
```
Install into user profile:
```bash
nix profile add github:vrubelroman/vcom?ref=v0.2.5
```
The Nix package wraps `vcom` with `ueberzugpp` in `PATH`, so image preview works in non-`kitty` terminals out of the box.
### Debian / Ubuntu
Download and install the latest release:
```bash
curl -sL https://github.com/vrubelroman/vcom/releases/download/v0.2.5/vcom_0.2.4_amd64.deb -o /tmp/vcom_0.2.4_amd64.deb
sudo apt install /tmp/vcom_0.2.4_amd64.deb
```
The Debian package declares `ueberzug` (or `ueberzugpp` where available) as a dependency for image preview outside `kitty`.
### Arch Linux
A `PKGBUILD` is included in the repository:
```bash
makepkg -si
```
The Arch package depends on `ueberzugpp`, so non-`kitty` image preview is installed together with `vcom`.
## Font requirement (icons)
For file icons, `vcom` expects a Nerd Font in your terminal profile.
Default behavior is `ui.icon_mode = "auto"`:
- if a Nerd Font is detected, `vcom` uses Nerd icons
- if not, `vcom` falls back to ASCII icons automatically
You can force behavior in config:
- `ui.icon_mode = "nerd"`: always use Nerd icons
- `ui.icon_mode = "ascii"`: always use ASCII icons
### Installing a Nerd Font
**Ubuntu / Debian:**
```bash
wget -qO /tmp/JetBrainsMono.zip https://github.com/ryanoasis/nerd-fonts/releases/latest/download/JetBrainsMono.zip
mkdir -p ~/.local/share/fonts/JetBrainsMonoNerd
unzip -o /tmp/JetBrainsMono.zip -d ~/.local/share/fonts/JetBrainsMonoNerd
fc-cache -fv
```
**Arch Linux:**
```bash
sudo pacman -S ttf-jetbrains-mono-nerd
```
Or via AUR helper:
```bash
yay -S nerd-fonts-jetbrains-mono
```
**NixOS / Nix:**
Add to your `/etc/nixos/configuration.nix`:
```nix
fonts.packages = with pkgs; [ nerd-fonts.jetbrains-mono ];
```
Or install imperatively:
```bash
nix profile install nixpkgs#nerd-fonts.jetbrains-mono
```
### Configuring terminal to use the installed Nerd Font
After installing, set `JetBrainsMono Nerd Font` (or another Nerd Font) as the terminal font:
- **GNOME Terminal:** `Preferences → Profile → Text → Custom font` → choose `JetBrainsMono Nerd Font`
- **Konsole:** `Settings → Edit Current Profile → Appearance` → choose a Nerd Font profile
- **Alacritty:** set `font.normal.family: "JetBrainsMono Nerd Font"` in `~/.config/alacritty/alacritty.yml`
- **Kitty:** set `font_family JetBrainsMono Nerd Font` in `~/.config/kitty/kitty.conf`
- **Foot:** set `font=JetBrainsMono Nerd Font:size=11` in `~/.config/foot/foot.ini`
- **WezTerm:** set `font = wezterm.font("JetBrainsMono Nerd Font")` in `~/.config/wezterm/wezterm.lua`
- **Windows Terminal:** `Settings → Profiles → Defaults → Appearance → Font face` → choose `JetBrainsMono Nerd Font`
Preview mode (`F9` / `i`) temporarily replaces the inactive pane and shows:
- directory listing preview
- text file preview with syntax highlighting
- image metadata (format + dimensions)
- safe fallback for binary files
## Configuration
Optional config lookup order:
1. `-config /path/to/vcom.toml`
2. `./vcom.toml`
3. `./config/vcom.toml`
4. `$XDG_CONFIG_HOME/vcom/vcom.toml`
5. `~/.config/vcom/vcom.toml`
Reference config: [vcom.toml](/home/vrubel/projects/vcom/vcom.toml)
Icon mode example:
```toml
[ui]
icon_mode = "auto" # auto | nerd | ascii
```
## Themes
Built-in themes (press `t` to open theme selector or set `ui.theme` in config):
- `catppuccin-mocha` (default)
- `catppuccin-macchiato`
- `catppuccin-lavender`
- `tokyo-night`
- `gruvbox-dark`
- `nord`
- `one-dark`
- `everforest`
- `github-dark`
- `ayu-dark`
- `breeze`
- `cyberpunk`
- `dracula`
- `eldritch`
- `kanagawa`
- `kanagawa-paper`
- `rose-pine`
- `solarized-dark`
- `vesper`
## Releases
Pushing a tag like `v0.2.5` triggers GitHub Actions release workflow (`.github/workflows/release.yml`) which:
- runs tests
- vendors Go modules
- builds release binary
- builds Debian package
- publishes release assets
Release artifacts:
- `vcom-v0.2.5-x86_64-unknown-linux-gnu.tar.gz`
- `vcom_0.2.4_amd64.deb`
- `vcom-v0.2.5-checksums.txt`
## Notes
- File creation time depends on filesystem/OS support; unavailable values are shown as `n/a`.
Architecture notes: [docs/architecture.md](/home/vrubel/projects/vcom/docs/architecture.md)

View file

@ -0,0 +1,196 @@
# Architecture
## Why this shape
The project should not become a giant `main.go` that mixes rendering, key handling, filesystem I/O and business rules. The architecture is split so the UI remains reactive and file operations stay isolated.
## High-level modules
- `cmd/vcom`
Entry point, config loading, program startup.
- `internal/config`
Config schema, defaults, search paths and TOML parsing.
- `internal/fs`
Filesystem model, directory scanning, previews and file operations.
- `internal/theme`
Theme presets and style tokens.
- `internal/ui`
Bubble Tea model, key map, pane rendering, modal flow and layout.
## State model
The Bubble Tea root model owns:
- terminal dimensions
- full config
- active browser pane
- left and right pane state
- center preview state
- transient modal state
- busy/status state for async work
This keeps the Elm-style update loop simple:
1. key or resize event arrives
2. model decides whether to mutate local state or start async work
3. async work returns a typed message
4. view renders from plain state
## Pane model
Each side pane stores:
- current path
- scanned entries
- cursor index
- scroll offset
- cached directory sizes for entries calculated on demand
This is intentionally independent from rendering details, so the pane can be unit-tested later without Lip Gloss.
## Preview pipeline
The center pane is driven only by the current selection from the active side pane.
Preview strategies:
- directory preview
Shows child entries and summary data.
- text preview
Reads up to a configured byte limit and displays text safely.
- image preview
Detects dimensions and format through standard Go image decoders.
- binary fallback
Avoids dumping junk bytes into the terminal.
Metadata shown above the preview:
- kind
- full path
- size
- created time
- modified time
- permissions
Directory size is expensive, so it is not calculated eagerly. Pressing `Space` starts an async scan and updates both:
- side-pane size column
- preview metadata widget
## Configuration design
TOML is used because:
- it is readable in terminal workflows
- comments are first-class
- optional settings can stay commented out for manual enable/disable
The example config enables only MC-like columns by default:
- `name`
- `size`
- `modified`
Additional columns are left commented out so users can literally uncomment them:
- `created`
- `permissions`
- `extension`
Other useful config areas:
- startup paths for left and right panes
- theme preset
- hidden file visibility
- sort field and reverse mode
- center pane width ratio
- confirmation behavior
- preview byte limits
- text wrapping in preview
- compact path display
## Rendering strategy
The side panes are custom-rendered rather than built on top of the generic table bubble.
Reasoning:
- MC-like directory panes are not just tables
- we need tight control over selection styling, truncation and empty states
- the center pane uses a different rendering model than the side panes
Bubble usage:
- `bubbletea`
event loop and async commands
- `bubbles/key`
declarative key bindings
- `bubbles/textinput`
mkdir modal
- `bubbles/viewport`
preview scrolling surface
- `lipgloss`
all layout and styling
## Operations
The first operational slice is intentionally MC-core:
- copy selected entry to passive pane directory
- move selected entry to passive pane directory
- create directory in active pane
- delete selected entry
- refresh active pane
These operations are dispatched asynchronously to avoid freezing the TUI on large directories.
Overwrite handling is decided before the async operation begins:
- if the target does not exist, the operation runs directly
- if the target exists and overwrite confirmation is enabled, the UI opens a modal
- if overwrite confirmation is disabled, the operation replaces the target immediately
## Theme system
Themes are token-based rather than style-object based.
Each preset defines semantic colors:
- background
- panel
- panel inactive
- border
- border active
- text
- muted text
- accent
- selection
- warning
- danger
- footer key
This allows future user-defined themes without touching UI logic.
The current UI also supports runtime theme cycling, but config remains the source of truth for default startup appearance.
## Extension points
The architecture is designed to accept later additions without a rewrite:
- multi-select and batch operations
- file viewer/editor integration
- terminal image protocols
- tab history per pane
- bookmarks and quick-jump
- sort modes
- archive browsing
- search/filter overlay
- pluggable preview providers
## Constraints worth keeping
- never calculate directory sizes during normal navigation
- never read full large files into memory for preview
- keep filesystem code out of `View()`
- keep styling decisions out of `internal/fs`
- prefer typed Tea messages over stringly-typed status events

View file

@ -0,0 +1,128 @@
# vcom: описание проекта и реализованного функционала
`vcom` — терминальный файловый менеджер в стиле Midnight Commander, написанный на Go на базе Bubble Tea.
## 1. Основная концепция
Приложение работает в двухпанельном режиме:
- левая файловая панель,
- правая файловая панель.
Активная панель управляется с клавиатуры и мыши, неактивная сохраняет своё состояние (путь, позицию курсора).
## 2. Реализованные режимы интерфейса
- Двухпанельный браузер директорий.
- Режим Info/Preview (`i`): неактивная панель временно заменяется превью выбранного элемента из активной панели.
- Режим выделения текста в превью (`Ctrl+t`) для текстовых файлов.
- Модальные окна (подтверждения, прогресс операций, help, уведомления).
## 3. Навигация и просмотр
- Перемещение по списку: `j/k`, `Up/Down`, `PgUp/PgDn`.
- Переключение активной панели: `Tab`, `h`, `l`.
- Вход в директорию: `Enter` / `Right`.
- Переход в родительскую директорию: `Backspace` / `Left`.
- Обновление панелей: `r`.
- Внешний просмотр файла: `F3` (`$PAGER` при наличии).
- Внешнее редактирование файла: `F4` (`$VISUAL/$EDITOR` или fallback-редакторы).
## 4. Операции с файлами и директориями
- `F5` — копирование.
- `F6` — перемещение.
- `F7` — создание директории.
- `F8` — удаление.
Операции copy/move реализованы с:
- предварительным диалогом подтверждения,
- подсчётом объёма и количества файлов,
- прогрессом по байтам и по количеству файлов,
- возможностью отправить операцию в фон (`b`),
- уведомлением о завершении фоновой операции.
Подтверждение overwrite учитывается для существующих целей.
## 5. Мультивыделение
Реализовано выделение элементов с клавиатуры:
- `Shift+Up/Shift+Down` (а также `Shift+K/Shift+J`) добавляют/снимают выделение на текущем проходе.
- Повторный проход по уже выделенному элементу снимает его выделение (toggle).
- `Esc` очищает выделение активной панели.
Если есть выделенные элементы, `F5/F6/F8` применяются ко всему выделенному набору.
Если выделения нет — операция применяется к текущему элементу под курсором.
## 6. Работа мыши
- ЛКМ: выбор элемента и активация панели.
- Двойной ЛКМ: открытие элемента.
- ПКМ: переключение режима Info/Preview для выбранного элемента.
- Колесо мыши: прокрутка списка; в preview-области — прокрутка содержимого превью.
## 7. Help-окно
`F1` или `?` открывает справку по управлению.
Особенности help:
- логические блоки (Navigation, View and Panels, Dialogs and Transfers, Mouse),
- цветовое оформление заголовков и элементов на основе активной темы,
- закрытие по `F1`, `?`, `Esc`, `Enter`, `q`.
## 8. Модальные окна и закрытие
Во всех модальных окнах поддержано закрытие по `q`.
Поведение в прогрессе copy/move:
- `q` не прерывает операцию,
- окно закрывается,
- операция продолжается в фоне.
## 9. Визуальные доработки
- Убрана верхняя title-строка приложения.
- Убраны текстовые лейблы `LEFT/RIGHT` в заголовках панелей.
- Убрана строка `CONTENT` в preview-панели.
- Путь активной панели сделан жирным и в цвете `TextFile` текущей темы.
- Подсветка курсора отображается только в активной панели.
- Выделенные (marked) элементы подсвечиваются цветом `Danger` темы по всей строке.
## 10. Конфигурация
Поддерживается TOML-конфиг (`vcom.toml`), включая:
- стартовые директории,
- визуальные параметры UI,
- набор и видимость колонок,
- сортировку,
- поведение превью,
- поведение подтверждений и операций.
## 11. Технологический стек
- Go
- Bubble Tea (`github.com/charmbracelet/bubbletea`)
- Bubbles
- Lip Gloss
- TOML (`pelletier/go-toml/v2`)
## 12. Сборка и запуск
Локально:
```bash
go run ./cmd/vcom
```
Сборка бинаря:
```bash
go build -o vcom ./cmd/vcom
```
Nix:
```bash
nix run .
```
Для запуска тегов из GitHub-репозитория рекомендуется использовать версии с `v0.1.1` и выше.

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

61
src/vcom-0.2.5/flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1776548001,
"narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

59
src/vcom-0.2.5/flake.nix Normal file
View file

@ -0,0 +1,59 @@
{
description = "vcom terminal file manager";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
lib = pkgs.lib;
packageBase = pkgs.buildGoModule {
pname = "vcom";
version = "0.2.5";
src = ./.;
vendorHash = null;
subPackages = [ "cmd/vcom" ];
ldflags = [
"-s"
"-w"
];
meta = with lib; {
description = "Terminal file manager inspired by Midnight Commander";
homepage = "https://github.com/vrubelroman/vcom";
license = licenses.mit;
mainProgram = "vcom";
platforms = platforms.linux;
};
};
package = pkgs.symlinkJoin {
name = "vcom";
paths = [ packageBase ];
nativeBuildInputs = [ pkgs.makeWrapper ];
postBuild = ''
wrapProgram "$out/bin/vcom" \
--prefix PATH : "${lib.makeBinPath [ pkgs.ueberzugpp ]}"
'';
};
in {
packages.default = package;
apps.default = {
type = "app";
program = "${package}/bin/vcom";
};
devShells.default = pkgs.mkShell {
packages = with pkgs; [
go
gopls
];
};
});
}

36
src/vcom-0.2.5/go.mod Normal file
View file

@ -0,0 +1,36 @@
module vcom
go 1.26.0
require (
github.com/alecthomas/chroma/v2 v2.23.1
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/pelletier/go-toml/v2 v2.2.4
golang.org/x/sys v0.43.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pkg/sftp v1.13.10 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/text v0.36.0 // indirect
)

63
src/vcom-0.2.5/go.sum Normal file
View file

@ -0,0 +1,63 @@
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
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/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=

View file

@ -0,0 +1,273 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
toml "github.com/pelletier/go-toml/v2"
)
type Config struct {
Startup StartupConfig `toml:"startup"`
UI UIConfig `toml:"ui"`
Browser BrowserConfig `toml:"browser"`
Preview PreviewConfig `toml:"preview"`
Behavior BehaviorConfig `toml:"behavior"`
}
type StartupConfig struct {
LeftPath string `toml:"left_path"`
RightPath string `toml:"right_path"`
}
type UIConfig struct {
AppTitle string `toml:"app_title"`
Theme string `toml:"theme"`
IconMode string `toml:"icon_mode"`
ShowTitleBar bool `toml:"show_title_bar"`
ShowFooter bool `toml:"show_footer"`
Border string `toml:"border"`
PathDisplay string `toml:"path_display"`
PaneGap int `toml:"pane_gap"`
CenterWidthPercent int `toml:"center_width_percent"`
}
type BrowserConfig struct {
ShowHidden bool `toml:"show_hidden"`
DirsFirst bool `toml:"dirs_first"`
HumanReadableSize bool `toml:"human_readable_size"`
Sort SortConfig `toml:"sort"`
Columns BrowserColumnsConfig `toml:"columns"`
}
type SortConfig struct {
By string `toml:"by"`
Reverse bool `toml:"reverse"`
}
type BrowserColumnsConfig struct {
Name bool `toml:"name"`
Size bool `toml:"size"`
Modified bool `toml:"modified"`
Created bool `toml:"created"`
Permissions bool `toml:"permissions"`
Extension bool `toml:"extension"`
}
type PreviewConfig struct {
ShowMetadata bool `toml:"show_metadata"`
WrapText bool `toml:"wrap_text"`
MaxPreviewBytes int64 `toml:"max_preview_bytes"`
DirectoryPreviewLimit int `toml:"directory_preview_limit"`
}
type BehaviorConfig struct {
ConfirmDelete bool `toml:"confirm_delete"`
ConfirmOverwrite bool `toml:"confirm_overwrite"`
CalculateDirSizeOnSpace bool `toml:"calculate_dir_size_on_space"`
FollowSymlinks bool `toml:"follow_symlinks"`
}
func Default() Config {
return Config{
Startup: StartupConfig{},
UI: UIConfig{
AppTitle: "vcom",
Theme: "catppuccin-mocha",
IconMode: "auto",
ShowTitleBar: true,
ShowFooter: true,
Border: "rounded",
PathDisplay: "short",
PaneGap: 1,
CenterWidthPercent: 30,
},
Browser: BrowserConfig{
ShowHidden: true,
DirsFirst: true,
HumanReadableSize: true,
Sort: SortConfig{
By: "name",
Reverse: false,
},
Columns: BrowserColumnsConfig{
Name: true,
Size: true,
Modified: true,
},
},
Preview: PreviewConfig{
ShowMetadata: true,
WrapText: false,
MaxPreviewBytes: 64 * 1024,
DirectoryPreviewLimit: 80,
},
Behavior: BehaviorConfig{
ConfirmDelete: true,
ConfirmOverwrite: true,
CalculateDirSizeOnSpace: true,
FollowSymlinks: false,
},
}
}
func Load(explicitPath string) (Config, string, error) {
cfg := Default()
path, found, err := resolvePath(explicitPath)
if err != nil {
return Config{}, "", err
}
if !found {
return cfg, "", nil
}
data, err := os.ReadFile(path)
if err != nil {
return Config{}, "", fmt.Errorf("read %s: %w", path, err)
}
if err := toml.Unmarshal(data, &cfg); err != nil {
return Config{}, "", fmt.Errorf("parse %s: %w", path, err)
}
if err := cfg.Validate(); err != nil {
return Config{}, "", fmt.Errorf("validate %s: %w", path, err)
}
return cfg, path, nil
}
func Save(cfg Config, path string) (string, error) {
if err := cfg.Validate(); err != nil {
return "", err
}
targetPath := strings.TrimSpace(path)
if targetPath == "" {
var err error
targetPath, err = DefaultUserPath()
if err != nil {
return "", err
}
}
absPath, err := filepath.Abs(targetPath)
if err != nil {
return "", fmt.Errorf("resolve %s: %w", targetPath, err)
}
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(absPath), err)
}
data, err := toml.Marshal(cfg)
if err != nil {
return "", fmt.Errorf("marshal config: %w", err)
}
if err := os.WriteFile(absPath, data, 0o644); err != nil {
return "", fmt.Errorf("write %s: %w", absPath, err)
}
return absPath, nil
}
func (c *Config) Validate() error {
if c.UI.Theme == "" {
return errors.New("ui.theme must not be empty")
}
switch strings.ToLower(strings.TrimSpace(c.UI.IconMode)) {
case "", "auto":
c.UI.IconMode = "auto"
case "nerd", "ascii":
default:
return errors.New("ui.icon_mode must be one of: auto, nerd, ascii")
}
if strings.TrimSpace(c.UI.AppTitle) == "" {
c.UI.AppTitle = "vcom"
}
if c.Preview.MaxPreviewBytes <= 0 {
return errors.New("preview.max_preview_bytes must be > 0")
}
if c.Preview.DirectoryPreviewLimit <= 0 {
return errors.New("preview.directory_preview_limit must be > 0")
}
if !c.Browser.Columns.Name {
return errors.New("browser.columns.name must stay enabled")
}
if strings.TrimSpace(c.UI.PathDisplay) == "" {
c.UI.PathDisplay = "short"
}
if c.UI.PaneGap < 0 || c.UI.PaneGap > 4 {
return errors.New("ui.pane_gap must be between 0 and 4")
}
if c.UI.CenterWidthPercent < 20 || c.UI.CenterWidthPercent > 60 {
return errors.New("ui.center_width_percent must be between 20 and 60")
}
switch strings.ToLower(strings.TrimSpace(c.Browser.Sort.By)) {
case "", "name":
c.Browser.Sort.By = "name"
case "modified", "size", "created", "extension":
default:
return fmt.Errorf("browser.sort.by must be one of: name, modified, size, created, extension")
}
return nil
}
func resolvePath(explicitPath string) (string, bool, error) {
var candidates []string
if explicitPath != "" {
candidates = append(candidates, explicitPath)
} else {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", false, fmt.Errorf("resolve home dir: %w", err)
}
xdgDir := os.Getenv("XDG_CONFIG_HOME")
if xdgDir == "" {
xdgDir = filepath.Join(homeDir, ".config")
}
candidates = append(candidates,
"vcom.toml",
filepath.Join("config", "vcom.toml"),
filepath.Join(xdgDir, "vcom", "vcom.toml"),
filepath.Join(homeDir, ".config", "vcom", "vcom.toml"),
)
}
for _, candidate := range candidates {
if candidate == "" {
continue
}
absPath, err := filepath.Abs(candidate)
if err != nil {
return "", false, fmt.Errorf("resolve %s: %w", candidate, err)
}
if _, err := os.Stat(absPath); err == nil {
return absPath, true, nil
} else if !isMissingPathError(err) {
return "", false, fmt.Errorf("stat %s: %w", absPath, err)
}
}
return "", false, nil
}
func isMissingPathError(err error) bool {
return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR)
}
func DefaultUserPath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home dir: %w", err)
}
xdgDir := os.Getenv("XDG_CONFIG_HOME")
if xdgDir == "" {
xdgDir = filepath.Join(homeDir, ".config")
}
return filepath.Join(xdgDir, "vcom", "vcom.toml"), nil
}

View file

@ -0,0 +1,84 @@
package config
import (
"fmt"
"os"
"path/filepath"
toml "github.com/pelletier/go-toml/v2"
)
// SessionState stores the UI state that should be restored on next launch.
type SessionState struct {
ActivePane string `toml:"active_pane"`
Left PaneSession `toml:"left"`
Right PaneSession `toml:"right"`
}
// PaneSession stores per-pane session state (path and selected entry name).
type PaneSession struct {
Path string `toml:"path"`
EntryName string `toml:"entry_name"`
CursorMemory map[string]string `toml:"cursor_memory"`
}
// DefaultSessionPath returns the path to the session file.
func DefaultSessionPath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home dir: %w", err)
}
xdgDir := os.Getenv("XDG_CONFIG_HOME")
if xdgDir == "" {
xdgDir = filepath.Join(homeDir, ".config")
}
return filepath.Join(xdgDir, "vcom", "session.toml"), nil
}
// LoadSession reads the session state from disk.
// Returns (SessionState, nil) on success, (empty, error) if file doesn't exist.
func LoadSession() (SessionState, error) {
path, err := DefaultSessionPath()
if err != nil {
return SessionState{}, err
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return SessionState{}, nil
}
return SessionState{}, fmt.Errorf("read %s: %w", path, err)
}
var s SessionState
if err := toml.Unmarshal(data, &s); err != nil {
return SessionState{}, fmt.Errorf("parse %s: %w", path, err)
}
return s, nil
}
// SaveSession writes the session state to disk.
func SaveSession(s SessionState) error {
path, err := DefaultSessionPath()
if err != nil {
return err
}
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("resolve %s: %w", path, err)
}
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(absPath), err)
}
data, err := toml.Marshal(s)
if err != nil {
return fmt.Errorf("marshal session: %w", err)
}
if err := os.WriteFile(absPath, data, 0o644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}

View file

@ -0,0 +1,656 @@
package vfs
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
)
func ExtractArchiveToTemp(sourcePath string) (string, error) {
// Count total files for progress reporting
totalFiles, totalBytes := countArchiveEntries(sourcePath)
tempDir, err := os.MkdirTemp("", "vcom-archive-")
if err != nil {
return "", err
}
cleanupOnErr := func(extractErr error) (string, error) {
_ = os.RemoveAll(tempDir)
return "", extractErr
}
// Use background context for temp extraction (no cancellation needed)
ctx := context.Background()
sourceLower := strings.ToLower(sourcePath)
switch {
case strings.HasSuffix(sourceLower, ".zip"):
if err := extractZipArchive(ctx, sourcePath, tempDir, nil, totalFiles, totalBytes); err != nil {
return cleanupOnErr(err)
}
case strings.HasSuffix(sourceLower, ".tar"):
if err := extractTarArchive(ctx, sourcePath, tempDir, false, nil, totalFiles, totalBytes); err != nil {
return cleanupOnErr(err)
}
case strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"):
if err := extractTarArchive(ctx, sourcePath, tempDir, true, nil, totalFiles, totalBytes); err != nil {
return cleanupOnErr(err)
}
default:
return cleanupOnErr(fmt.Errorf("archive format is not supported: %s", filepath.Ext(sourcePath)))
}
return tempDir, nil
}
// ExtractArchiveToDir extracts an archive to the specified target directory.
// Unlike ExtractArchiveToTemp, it extracts directly to targetDir without
// creating a temporary directory. The progress callback is called after each
// file is extracted; it may be nil. Cancellation is supported via ctx.
func ExtractArchiveToDir(ctx context.Context, sourcePath, targetDir string, progress func(CopyProgress)) error {
totalFiles, totalBytes := countArchiveEntries(sourcePath)
sourceLower := strings.ToLower(sourcePath)
switch {
case strings.HasSuffix(sourceLower, ".zip"):
return extractZipArchive(ctx, sourcePath, targetDir, progress, totalFiles, totalBytes)
case strings.HasSuffix(sourceLower, ".tar"):
return extractTarArchive(ctx, sourcePath, targetDir, false, progress, totalFiles, totalBytes)
case strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"):
return extractTarArchive(ctx, sourcePath, targetDir, true, progress, totalFiles, totalBytes)
default:
return fmt.Errorf("archive format is not supported: %s", filepath.Ext(sourcePath))
}
}
// countArchiveEntries counts the total number of files and total uncompressed
// bytes in an archive without extracting. Used for progress reporting.
func countArchiveEntries(sourcePath string) (int64, int64) {
sourceLower := strings.ToLower(sourcePath)
switch {
case strings.HasSuffix(sourceLower, ".zip"):
return countZipEntries(sourcePath)
case strings.HasSuffix(sourceLower, ".tar"), strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"):
return countTarEntries(sourcePath)
default:
return 0, 0
}
}
func countZipEntries(sourcePath string) (int64, int64) {
r, err := zip.OpenReader(sourcePath)
if err != nil {
return 0, 0
}
defer r.Close()
var files, bytes int64
for _, f := range r.File {
if !f.FileInfo().IsDir() {
files++
bytes += int64(f.UncompressedSize64)
}
}
return files, bytes
}
func countTarEntries(sourcePath string) (int64, int64) {
f, err := os.Open(sourcePath)
if err != nil {
return 0, 0
}
defer f.Close()
var reader io.Reader = f
if strings.HasSuffix(strings.ToLower(sourcePath), ".tar.gz") || strings.HasSuffix(strings.ToLower(sourcePath), ".tgz") {
gr, err := gzip.NewReader(f)
if err != nil {
return 0, 0
}
defer gr.Close()
reader = gr
}
tarReader := tar.NewReader(reader)
var files, bytes int64
for {
hdr, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
break
}
if hdr.Typeflag == tar.TypeReg || hdr.Typeflag == tar.TypeRegA {
files++
bytes += hdr.Size
}
}
return files, bytes
}
func extractZipArchive(ctx context.Context, sourcePath string, targetDir string, progress func(CopyProgress), totalFiles, totalBytes int64) error {
reader, err := zip.OpenReader(sourcePath)
if err != nil {
return err
}
defer reader.Close()
var filesDone int64
for _, file := range reader.File {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
relPath, ok := safeArchivePath(file.Name)
if !ok {
continue
}
fullPath := filepath.Join(targetDir, relPath)
if file.FileInfo().IsDir() {
if err := os.MkdirAll(fullPath, 0o755); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
return err
}
src, err := file.Open()
if err != nil {
return err
}
if err := writeArchiveFile(fullPath, src, file.Mode()); err != nil {
src.Close()
return err
}
src.Close()
filesDone++
if progress != nil {
progress(CopyProgress{
FilesDone: int(filesDone),
FilesTotal: int(totalFiles),
BytesDone: 0,
BytesTotal: totalBytes,
Stage: "Extracting data",
})
}
}
return nil
}
func extractTarArchive(ctx context.Context, sourcePath string, targetDir string, gzipped bool, progress func(CopyProgress), totalFiles, totalBytes int64) error {
file, err := os.Open(sourcePath)
if err != nil {
return err
}
defer file.Close()
var reader io.Reader = file
if gzipped {
gzipReader, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gzipReader.Close()
reader = gzipReader
}
tarReader := tar.NewReader(reader)
var filesDone int64
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
relPath, ok := safeArchivePath(header.Name)
if !ok {
continue
}
fullPath := filepath.Join(targetDir, relPath)
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(fullPath, 0o755); err != nil {
return err
}
case tar.TypeReg, tar.TypeRegA:
if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil {
return err
}
if err := writeArchiveFile(fullPath, tarReader, os.FileMode(header.Mode)); err != nil {
return err
}
filesDone++
if progress != nil {
progress(CopyProgress{
FilesDone: int(filesDone),
FilesTotal: int(totalFiles),
BytesDone: 0,
BytesTotal: totalBytes,
Stage: "Extracting data",
})
}
}
}
return nil
}
func writeArchiveFile(path string, source io.Reader, mode os.FileMode) error {
if mode == 0 {
mode = 0o644
}
output, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm())
if err != nil {
return err
}
defer output.Close()
_, err = io.Copy(output, source)
return err
}
func safeArchivePath(name string) (string, bool) {
clean := filepath.Clean(name)
if clean == "." || clean == string(filepath.Separator) {
return "", false
}
if filepath.IsAbs(clean) {
return "", false
}
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", false
}
return clean, true
}
// ArchiveFormat returns the file extension for a given archive format name.
func ArchiveFormat(format string) string {
switch strings.ToLower(strings.TrimSpace(format)) {
case "zip":
return ".zip"
case "tar":
return ".tar"
case "targz", "tar.gz", "tgz":
return ".tar.gz"
default:
return ".zip"
}
}
// ArchiveName generates an archive filename from source paths.
func ArchiveName(sources []string, format string) string {
ext := ArchiveFormat(format)
if len(sources) == 1 {
base := strings.TrimSuffix(filepath.Base(sources[0]), filepath.Ext(sources[0]))
return base + ext
}
base := filepath.Base(filepath.Dir(sources[0]))
if base == "." || base == "" || base == string(filepath.Separator) {
base = "archive"
}
return base + ext
}
// CreateArchive creates an archive from source paths using the given format.
// Supported formats: "zip", "tar", "tar.gz" (or "targz", "tgz").
// Progress is reported via the callback function.
func CreateArchive(ctx context.Context, sources []string, archivePath string, progress func(CopyProgress)) error {
if ctx == nil {
ctx = context.Background()
}
lower := strings.ToLower(archivePath)
switch {
case strings.HasSuffix(lower, ".zip"):
return createZipArchive(ctx, sources, archivePath, progress)
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
return createTarGzArchive(ctx, sources, archivePath, progress)
case strings.HasSuffix(lower, ".tar"):
return createTarArchive(ctx, sources, archivePath, progress)
default:
return fmt.Errorf("unsupported archive format: %s", filepath.Ext(archivePath))
}
}
func createZipArchive(ctx context.Context, sources []string, archivePath string, progress func(CopyProgress)) error {
file, err := os.Create(archivePath)
if err != nil {
return fmt.Errorf("create %s: %w", archivePath, err)
}
defer file.Close()
zipWriter := zip.NewWriter(file)
defer zipWriter.Close()
var totalFiles int
var totalBytes int64
for _, source := range sources {
info, err := os.Lstat(source)
if err != nil {
return fmt.Errorf("stat %s: %w", source, err)
}
if info.IsDir() {
err = filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
totalFiles++
if !info.IsDir() {
totalBytes += info.Size()
}
return nil
})
if err != nil {
return err
}
} else {
totalFiles++
totalBytes += info.Size()
}
}
state := &copyProgressState{
ctx: ctx,
stats: TransferStats{FilesTotal: totalFiles, BytesTotal: totalBytes},
callback: progress,
lastEmit: time.Now(),
}
baseDir := commonBaseDir(sources)
for _, source := range sources {
info, err := os.Lstat(source)
if err != nil {
return fmt.Errorf("stat %s: %w", source, err)
}
relRoot := source
if baseDir != "" {
relRoot, _ = filepath.Rel(baseDir, source)
}
if info.IsDir() {
err = filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
relPath, _ := filepath.Rel(baseDir, path)
relPath = filepath.ToSlash(relPath)
header, zipErr := zip.FileInfoHeader(info)
if zipErr != nil {
return zipErr
}
header.Name = relPath
if info.IsDir() {
header.Name += "/"
} else {
header.Method = zip.Deflate
}
writer, zipErr := zipWriter.CreateHeader(header)
if zipErr != nil {
return zipErr
}
if !info.IsDir() {
f, openErr := os.Open(path)
if openErr != nil {
return openErr
}
written, copyErr := io.Copy(writer, f)
f.Close()
if copyErr != nil {
return copyErr
}
state.filesDone++
state.bytesDone += written
} else {
state.filesDone++
}
emitArchiveProgress(state, path)
return nil
})
if err != nil {
return err
}
} else {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
relPath := filepath.ToSlash(relRoot)
header, zipErr := zip.FileInfoHeader(info)
if zipErr != nil {
return zipErr
}
header.Name = relPath
header.Method = zip.Deflate
writer, zipErr := zipWriter.CreateHeader(header)
if zipErr != nil {
return zipErr
}
f, openErr := os.Open(source)
if openErr != nil {
return openErr
}
written, copyErr := io.Copy(writer, f)
f.Close()
if copyErr != nil {
return copyErr
}
state.filesDone++
state.bytesDone += written
emitArchiveProgress(state, source)
}
}
return nil
}
func createTarArchive(ctx context.Context, sources []string, archivePath string, progress func(CopyProgress)) error {
return createTarArchiveWithGzip(ctx, sources, archivePath, false, progress)
}
func createTarGzArchive(ctx context.Context, sources []string, archivePath string, progress func(CopyProgress)) error {
return createTarArchiveWithGzip(ctx, sources, archivePath, true, progress)
}
func createTarArchiveWithGzip(ctx context.Context, sources []string, archivePath string, gzipped bool, progress func(CopyProgress)) error {
file, err := os.Create(archivePath)
if err != nil {
return fmt.Errorf("create %s: %w", archivePath, err)
}
defer file.Close()
var writer io.WriteCloser = file
if gzipped {
gzipWriter := gzip.NewWriter(file)
defer gzipWriter.Close()
writer = gzipWriter
}
tarWriter := tar.NewWriter(writer)
defer tarWriter.Close()
var totalFiles int
var totalBytes int64
for _, source := range sources {
info, err := os.Lstat(source)
if err != nil {
return fmt.Errorf("stat %s: %w", source, err)
}
if info.IsDir() {
err = filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
totalFiles++
if !info.IsDir() {
totalBytes += info.Size()
}
return nil
})
if err != nil {
return err
}
} else {
totalFiles++
totalBytes += info.Size()
}
}
state := &copyProgressState{
ctx: ctx,
stats: TransferStats{FilesTotal: totalFiles, BytesTotal: totalBytes},
callback: progress,
lastEmit: time.Now(),
}
baseDir := commonBaseDir(sources)
for _, source := range sources {
info, err := os.Lstat(source)
if err != nil {
return fmt.Errorf("stat %s: %w", source, err)
}
if info.IsDir() {
err = filepath.Walk(source, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
relPath, _ := filepath.Rel(baseDir, path)
relPath = filepath.ToSlash(relPath)
header, tarErr := tar.FileInfoHeader(info, path)
if tarErr != nil {
return tarErr
}
header.Name = relPath
if info.IsDir() {
header.Name += "/"
}
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if !info.IsDir() {
f, openErr := os.Open(path)
if openErr != nil {
return openErr
}
written, copyErr := io.Copy(tarWriter, f)
f.Close()
if copyErr != nil {
return copyErr
}
state.filesDone++
state.bytesDone += written
} else {
state.filesDone++
}
emitArchiveProgress(state, path)
return nil
})
if err != nil {
return err
}
} else {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
relPath, _ := filepath.Rel(baseDir, source)
relPath = filepath.ToSlash(relPath)
header, tarErr := tar.FileInfoHeader(info, source)
if tarErr != nil {
return tarErr
}
header.Name = relPath
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
f, openErr := os.Open(source)
if openErr != nil {
return openErr
}
written, copyErr := io.Copy(tarWriter, f)
f.Close()
if copyErr != nil {
return copyErr
}
state.filesDone++
state.bytesDone += written
emitArchiveProgress(state, source)
}
}
return nil
}
func emitArchiveProgress(state *copyProgressState, currentPath string) {
if state.callback == nil {
return
}
now := time.Now()
if now.Sub(state.lastEmit) < 50*time.Millisecond {
return
}
state.lastEmit = now
state.callback(CopyProgress{
FilesDone: state.filesDone,
FilesTotal: state.stats.FilesTotal,
BytesDone: state.bytesDone,
BytesTotal: state.stats.BytesTotal,
CurrentPath: currentPath,
Stage: "Archiving",
})
}
// commonBaseDir returns the longest common directory prefix for the given paths.
func commonBaseDir(paths []string) string {
if len(paths) == 0 {
return ""
}
if len(paths) == 1 {
if info, err := os.Lstat(paths[0]); err == nil && info.IsDir() {
return filepath.Dir(paths[0])
}
return filepath.Dir(paths[0])
}
base := filepath.Dir(paths[0])
for _, p := range paths[1:] {
dir := filepath.Dir(p)
for !strings.HasPrefix(dir, base) && base != "" {
parent := filepath.Dir(base)
if parent == base {
return ""
}
base = parent
}
}
return base
}

View file

@ -0,0 +1,156 @@
package vfs
import (
"io/fs"
"path/filepath"
"strings"
"time"
)
var (
configExtensions = map[string]struct{}{
"toml": {}, "yaml": {}, "yml": {}, "json": {}, "jsonc": {}, "ini": {}, "conf": {},
"config": {}, "env": {}, "properties": {}, "xml": {}, "mod": {}, "sum": {}, "lock": {},
}
textExtensions = map[string]struct{}{
"txt": {}, "md": {}, "rst": {}, "go": {}, "rs": {}, "c": {}, "h": {}, "cpp": {}, "hpp": {},
"py": {}, "js": {}, "ts": {}, "tsx": {}, "jsx": {}, "java": {}, "kt": {}, "kts": {}, "swift": {},
"html": {}, "css": {}, "scss": {}, "sass": {}, "less": {}, "styl": {},
"sh": {}, "bash": {}, "zsh": {}, "fish": {}, "sql": {},
"log": {}, "csv": {}, "tsv": {},
"lua": {}, "rb": {}, "pl": {}, "pm": {}, "t": {}, "ps1": {}, "bat": {}, "cmd": {},
"vue": {}, "svelte": {}, "astro": {}, "ejs": {}, "hbs": {}, "pug": {}, "haml": {}, "php": {}, "twig": {},
"scala": {}, "groovy": {}, "clj": {}, "ex": {}, "exs": {}, "elm": {}, "hs": {}, "lisp": {}, "cl": {}, "rkt": {}, "scm": {}, "dart": {},
"tex": {}, "bib": {}, "sty": {}, "cls": {},
"gradle": {}, "cmake": {}, "mk": {}, "mak": {},
"asm": {}, "s": {}, "inc": {},
"patch": {}, "diff": {},
"proto": {}, "graphql": {}, "gql": {},
"tf": {}, "hcl": {},
"r": {}, "m": {}, "mm": {},
"nim": {}, "zig": {}, "odin": {}, "v": {}, "nix": {},
"cr": {}, "jl": {},
"erl": {}, "hrl": {},
}
// textFilenames lists common text files without a meaningful extension
// (like Makefile, Dockerfile, etc.) so they open in the editor.
textFilenames = map[string]struct{}{
"makefile": {}, "dockerfile": {}, "containerfile": {},
"readme": {}, "license": {}, "licence": {}, "copying": {}, "changelog": {}, "changes": {},
"todo": {}, "notes": {}, "authors": {}, "contributors": {}, "maintainers": {},
"procfile": {}, "gemfile": {}, "rakefile": {}, "snapfile": {}, "fastfile": {},
"cmakelists": {}, "justfile": {}, "taskfile": {},
"gitignore": {}, "gitattributes": {}, "gitmodules": {}, "gitkeep": {},
"gitconfig": {}, "git-blame-ignore-revs": {},
"editorconfig": {}, "envrc": {}, "hushlogin": {},
"xsession": {}, "xresources": {}, "xinitrc": {},
"bashrc": {}, "bash_profile": {}, "bash_logout": {},
"zshrc": {}, "zprofile": {}, "zlogin": {}, "zlogout": {},
"profile": {}, "inputrc": {}, "tmux.conf": {},
"npmrc": {}, "yarnrc": {}, "pnpmrc": {},
"eslintrc": {}, "prettierrc": {}, "babelrc": {},
"stylelintrc": {}, "commitlintrc": {},
"htaccess": {}, "htpasswd": {},
}
imageExtensions = map[string]struct{}{
"png": {}, "jpg": {}, "jpeg": {}, "gif": {}, "webp": {}, "bmp": {}, "svg": {}, "ico": {},
"avif": {}, "heic": {}, "heif": {}, "tiff": {}, "tif": {},
}
pdfExtensions = map[string]struct{}{
"pdf": {},
}
audioExtensions = map[string]struct{}{
"mp3": {}, "flac": {}, "ogg": {}, "opus": {}, "wav": {},
"aac": {}, "m4a": {}, "wma": {}, "dsf": {}, "ape": {},
}
videoExtensions = map[string]struct{}{
"mp4": {}, "mkv": {}, "mov": {}, "avi": {}, "webm": {},
"m4v": {}, "wmv": {}, "flv": {}, "ts": {}, "mts": {},
}
archiveExtensions = map[string]struct{}{
"zip": {}, "tar": {}, "gz": {}, "tgz": {}, "xz": {}, "bz2": {}, "7z": {}, "rar": {},
"zst": {}, "lz": {}, "lz4": {}, "lzma": {},
"iso": {}, "img": {}, "dmg": {},
}
)
type Entry struct {
Name string
Path string
Extension string
Mode fs.FileMode
Size int64
ModifiedAt time.Time
CreatedAt time.Time
CreatedKnown bool
IsDir bool
IsParent bool
IsHidden bool
IsRemote bool
Connected bool
DirSizeKnown bool
RemoteHostName string
}
func (e Entry) DisplayName() string {
if e.IsParent {
return ".."
}
if e.IsDir {
return e.Name + "/"
}
return e.Name
}
func (e Entry) IsFile() bool {
return !e.IsDir && !e.IsParent
}
func (e Entry) MatchKey() string {
return strings.ToLower(e.Name)
}
func (e Entry) IsExecutable() bool {
return !e.IsDir && !e.IsParent && e.Mode&0o111 != 0
}
func (e Entry) Category() string {
switch {
case e.IsParent:
return "parent"
case e.IsRemote:
return "remote"
case e.IsDir:
return "directory"
case hasExt(configExtensions, e.Extension):
return "config"
case hasExt(textExtensions, e.Extension):
return "text"
case hasExt(textFilenames, strings.ToLower(e.Name)):
return "text"
case hasExt(imageExtensions, e.Extension):
return "image"
case hasExt(pdfExtensions, e.Extension):
return "pdf"
case hasExt(audioExtensions, e.Extension):
return "audio"
case hasExt(videoExtensions, e.Extension):
return "video"
case hasExt(archiveExtensions, e.Extension):
return "archive"
case e.IsExecutable():
return "executable"
default:
return "binary"
}
}
func ext(name string) string {
value := strings.TrimPrefix(filepath.Ext(name), ".")
return strings.ToLower(value)
}
func hasExt(set map[string]struct{}, ext string) bool {
_, ok := set[strings.ToLower(ext)]
return ok
}

View file

@ -0,0 +1,527 @@
package vfs
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"syscall"
"time"
)
type TransferStats struct {
FilesTotal int
BytesTotal int64
}
type CopyProgress struct {
FilesDone int
FilesTotal int
BytesDone int64
BytesTotal int64
CurrentPath string
Stage string
}
type copyProgressState struct {
ctx context.Context
filesDone int
bytesDone int64
stats TransferStats
callback func(CopyProgress)
lastEmit time.Time
stage string
discover bool // if true, count files during copy for progress total
}
func (s *copyProgressState) discoverFiles(count int, dirPath string) {
if count == 0 {
return
}
s.stats.FilesTotal += count
s.emit(dirPath, false)
}
func CopyPath(srcPath string, dstDir string, overwrite bool) (string, error) {
return CopyPathWithProgress(srcPath, dstDir, overwrite, TransferStats{}, nil)
}
func CopyPathWithProgressContext(ctx context.Context, srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
if ctx == nil {
ctx = context.Background()
}
srcInfo, err := os.Lstat(srcPath)
if err != nil {
return "", fmt.Errorf("stat %s: %w", srcPath, err)
}
targetPath := filepath.Join(dstDir, filepath.Base(srcPath))
if same, err := samePath(srcPath, targetPath); err != nil {
return "", err
} else if same {
return "", fmt.Errorf("source and target are the same: %s", targetPath)
}
if exists, err := PathExists(targetPath); err != nil {
return "", err
} else if exists {
if !overwrite {
return "", ErrOverwrite(targetPath)
}
if err := os.RemoveAll(targetPath); err != nil {
return "", err
}
}
if err := ctx.Err(); err != nil {
return "", err
}
if progress == nil {
progress = func(CopyProgress) {}
}
tracker := copyProgressState{
ctx: ctx,
stats: stats,
callback: progress,
stage: "Scanning files...",
discover: stats.FilesTotal == 0,
}
tracker.emit(srcPath, true)
tracker.stage = "Copying files..."
cleanupOnErr := func(copyErr error) (string, error) {
if copyErr != nil {
_ = os.RemoveAll(targetPath)
}
return "", copyErr
}
if srcInfo.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(srcPath)
if err != nil {
return "", err
}
if err := ctx.Err(); err != nil {
return "", err
}
if err := os.Symlink(target, targetPath); err != nil {
return "", err
}
tracker.finishFile(srcPath)
return targetPath, nil
}
if srcInfo.IsDir() {
if err := copyDir(srcPath, targetPath, &tracker); err != nil {
return cleanupOnErr(err)
}
if err := ctx.Err(); err != nil {
return cleanupOnErr(err)
}
tracker.emit(srcPath, true)
return targetPath, nil
}
if err := copyFile(srcPath, targetPath, srcInfo.Mode(), &tracker); err != nil {
return cleanupOnErr(err)
}
tracker.emit(srcPath, true)
return targetPath, nil
}
func CopyStats(srcPath string) (TransferStats, error) {
srcInfo, err := os.Lstat(srcPath)
if err != nil {
return TransferStats{}, fmt.Errorf("stat %s: %w", srcPath, err)
}
if srcInfo.Mode()&os.ModeSymlink != 0 {
return TransferStats{FilesTotal: 1, BytesTotal: 0}, nil
}
if !srcInfo.IsDir() {
return TransferStats{FilesTotal: 1, BytesTotal: srcInfo.Size()}, nil
}
stats := TransferStats{}
err = filepath.WalkDir(srcPath, func(current string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
stats.FilesTotal++
return nil
})
if err != nil {
return TransferStats{}, err
}
return stats, nil
}
func CopyPathWithProgress(srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
return CopyPathWithProgressContext(context.Background(), srcPath, dstDir, overwrite, stats, progress)
}
func MovePath(srcPath string, dstDir string, overwrite bool) (string, error) {
return MovePathWithProgress(srcPath, dstDir, overwrite, TransferStats{}, nil)
}
func MovePathWithProgress(srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
return MovePathWithProgressContext(context.Background(), srcPath, dstDir, overwrite, stats, progress)
}
func MovePathWithProgressContext(ctx context.Context, srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
if ctx == nil {
ctx = context.Background()
}
targetPath := filepath.Join(dstDir, filepath.Base(srcPath))
if same, err := samePath(srcPath, targetPath); err != nil {
return "", err
} else if same {
return "", fmt.Errorf("source and target are the same: %s", targetPath)
}
if exists, err := PathExists(targetPath); err != nil {
return "", err
} else if exists {
if !overwrite {
return "", ErrOverwrite(targetPath)
}
if err := os.RemoveAll(targetPath); err != nil {
return "", err
}
}
if progress == nil {
progress = func(CopyProgress) {}
}
if err := ctx.Err(); err != nil {
return "", err
}
if err := os.Rename(srcPath, targetPath); err == nil {
progress(CopyProgress{
FilesDone: stats.FilesTotal,
FilesTotal: stats.FilesTotal,
BytesDone: stats.BytesTotal,
BytesTotal: stats.BytesTotal,
CurrentPath: srcPath,
Stage: "Move completed",
})
return targetPath, nil
} else if !errors.Is(err, syscall.EXDEV) {
return "", err
}
targetPath, err := CopyPathWithProgressContext(ctx, srcPath, dstDir, overwrite, stats, progress)
if err != nil {
return "", err
}
if err := ctx.Err(); err != nil {
_ = os.RemoveAll(targetPath)
return "", err
}
progress(CopyProgress{
FilesDone: stats.FilesTotal,
FilesTotal: stats.FilesTotal,
BytesDone: stats.BytesTotal,
BytesTotal: stats.BytesTotal,
CurrentPath: srcPath,
Stage: "Finalizing move",
})
if err := DeletePath(srcPath); err != nil {
return "", err
}
return targetPath, nil
}
func PathExists(path string) (bool, error) {
if _, err := os.Lstat(path); err == nil {
return true, nil
} else if errors.Is(err, os.ErrNotExist) {
return false, nil
} else {
return false, err
}
}
func DeletePath(path string) error {
return os.RemoveAll(path)
}
// MoveToTrash moves a file or directory to the FreeDesktop Trash directory
// (~/.local/share/Trash). Follows the FreeDesktop Trash specification:
// - The original item is moved to Trash/files/<basename>
// - A .trashinfo file is written to Trash/info/<basename>.trashinfo
// - If <basename> already exists in Trash/files, a numeric suffix is appended.
func MoveToTrash(path string) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("cannot determine home directory: %w", err)
}
trashDir := filepath.Join(home, ".local", "share", "Trash")
filesDir := filepath.Join(trashDir, "files")
infoDir := filepath.Join(trashDir, "info")
if err := os.MkdirAll(filesDir, 0o700); err != nil {
return fmt.Errorf("cannot create trash files directory: %w", err)
}
if err := os.MkdirAll(infoDir, 0o700); err != nil {
return fmt.Errorf("cannot create trash info directory: %w", err)
}
baseName := filepath.Base(path)
// Generate a unique name in the trash directory
destName := baseName
for counter := 1; ; counter++ {
destPath := filepath.Join(filesDir, destName)
if _, err := os.Stat(destPath); os.IsNotExist(err) {
break
} else if err != nil {
return fmt.Errorf("cannot stat trash path: %w", err)
}
destName = fmt.Sprintf("%s.%d", baseName, counter)
}
destPath := filepath.Join(filesDir, destName)
if err := os.Rename(path, destPath); err != nil {
// Cross-filesystem move: fall back to copy+delete
return fmt.Errorf("cannot move to trash: %w", err)
}
// Write .trashinfo file
absPath, err := filepath.Abs(path)
if err != nil {
absPath = path
}
now := time.Now().Format("2006-01-02T15:04:05")
infoContent := fmt.Sprintf("[Trash Info]\nPath=%s\nDeletionDate=%s\n", absPath, now)
infoPath := filepath.Join(infoDir, destName+".trashinfo")
if err := os.WriteFile(infoPath, []byte(infoContent), 0o600); err != nil {
// Best-effort: if info file fails, try to move the file back
_ = os.Rename(destPath, path)
return fmt.Errorf("cannot write trash info: %w", err)
}
return nil
}
func MakeDir(parent string, name string) (string, error) {
target := filepath.Join(parent, name)
if err := os.MkdirAll(target, 0o755); err != nil {
return "", err
}
return target, nil
}
func RenamePath(sourcePath string, newName string) (string, error) {
newName = filepath.Base(filepath.Clean(newName))
if newName == "." || newName == "" {
return "", fmt.Errorf("invalid target name")
}
targetPath := filepath.Join(filepath.Dir(sourcePath), newName)
if same, err := samePath(sourcePath, targetPath); err != nil {
return "", err
} else if same {
return "", fmt.Errorf("source and target are the same: %s", targetPath)
}
if exists, err := PathExists(targetPath); err != nil {
return "", err
} else if exists {
return "", ErrOverwrite(targetPath)
}
if err := os.Rename(sourcePath, targetPath); err != nil {
return "", err
}
return targetPath, nil
}
func copyDir(srcDir string, dstDir string, tracker *copyProgressState) error {
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
info, err := os.Lstat(srcDir)
if err != nil {
return err
}
if err := os.MkdirAll(dstDir, info.Mode().Perm()); err != nil {
return err
}
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
}
// Count files in this directory so progress total converges
if tracker != nil && tracker.discover {
fileCount := 0
for _, entry := range entries {
if !entry.IsDir() {
fileCount++
}
}
tracker.discoverFiles(fileCount, srcDir)
}
for _, entry := range entries {
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
srcPath := filepath.Join(srcDir, entry.Name())
dstPath := filepath.Join(dstDir, entry.Name())
info, err := os.Lstat(srcPath)
if err != nil {
return err
}
switch {
case info.Mode()&os.ModeSymlink != 0:
target, err := os.Readlink(srcPath)
if err != nil {
return err
}
if err := os.Symlink(target, dstPath); err != nil {
return err
}
if tracker != nil {
tracker.finishFile(srcPath)
}
case info.IsDir():
if err := copyDir(srcPath, dstPath, tracker); err != nil {
return err
}
default:
if err := copyFile(srcPath, dstPath, info.Mode(), tracker); err != nil {
return err
}
}
}
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
return nil
}
func copyFile(srcPath string, dstPath string, mode os.FileMode, tracker *copyProgressState) error {
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
srcFile, err := os.Open(srcPath)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode.Perm())
if err != nil {
return err
}
defer dstFile.Close()
writer := io.Writer(dstFile)
if tracker != nil {
writer = &progressWriter{base: dstFile, tracker: tracker, path: srcPath}
}
if _, err := io.Copy(writer, srcFile); err != nil {
_ = dstFile.Close()
_ = os.Remove(dstPath)
return err
}
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
_ = dstFile.Close()
_ = os.Remove(dstPath)
return err
}
}
if tracker != nil {
tracker.finishFile(srcPath)
}
return nil
}
type progressWriter struct {
base io.Writer
tracker *copyProgressState
path string
}
func (w *progressWriter) Write(data []byte) (int, error) {
if w.tracker != nil && w.tracker.ctx != nil {
if err := w.tracker.ctx.Err(); err != nil {
return 0, err
}
}
n, err := w.base.Write(data)
if n > 0 {
w.tracker.addBytes(int64(n), w.path)
}
return n, err
}
func (s *copyProgressState) addBytes(delta int64, currentPath string) {
s.bytesDone += delta
s.emit(currentPath, false)
}
func (s *copyProgressState) finishFile(currentPath string) {
s.filesDone++
s.emit(currentPath, true)
}
func (s *copyProgressState) emit(currentPath string, force bool) {
if s.callback == nil {
return
}
if !force && time.Since(s.lastEmit) < 75*time.Millisecond {
return
}
s.lastEmit = time.Now()
stage := s.stage
if stage == "" {
stage = "Transferring data"
}
s.callback(CopyProgress{
FilesDone: s.filesDone,
FilesTotal: s.stats.FilesTotal,
BytesDone: s.bytesDone,
BytesTotal: s.stats.BytesTotal,
CurrentPath: currentPath,
Stage: stage,
})
}
func samePath(left string, right string) (bool, error) {
leftAbs, err := filepath.Abs(left)
if err != nil {
return false, err
}
rightAbs, err := filepath.Abs(right)
if err != nil {
return false, err
}
return leftAbs == rightAbs, nil
}

View file

@ -0,0 +1,135 @@
package vfs
import (
"context"
"errors"
"os"
"path/filepath"
"strconv"
"testing"
)
func TestCopyPathWithProgressContextRemovesPartialTargetOnCancel(t *testing.T) {
t.Parallel()
root := t.TempDir()
srcDir := filepath.Join(root, "src")
dstDir := filepath.Join(root, "dst")
if err := os.MkdirAll(srcDir, 0o755); err != nil {
t.Fatalf("mkdir src: %v", err)
}
if err := os.MkdirAll(dstDir, 0o755); err != nil {
t.Fatalf("mkdir dst: %v", err)
}
for idx := 0; idx < 64; idx++ {
path := filepath.Join(srcDir, "file-"+strconv.Itoa(idx)+".txt")
if err := os.WriteFile(path, []byte("payload-"+strconv.Itoa(idx)), 0o644); err != nil {
t.Fatalf("write source file %d: %v", idx, err)
}
}
stats, err := CopyStats(srcDir)
if err != nil {
t.Fatalf("copy stats: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
_, err = CopyPathWithProgressContext(ctx, srcDir, dstDir, false, stats, func(progress CopyProgress) {
if progress.FilesDone >= 1 {
cancel()
}
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context cancellation, got %v", err)
}
targetPath := filepath.Join(dstDir, filepath.Base(srcDir))
if _, statErr := os.Stat(targetPath); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("expected partial target to be removed, stat err=%v", statErr)
}
}
func TestMovePathWithProgressContextCancelledBeforeStartKeepsSource(t *testing.T) {
t.Parallel()
root := t.TempDir()
srcFile := filepath.Join(root, "source.txt")
dstDir := filepath.Join(root, "dst")
if err := os.WriteFile(srcFile, []byte("payload"), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
if err := os.MkdirAll(dstDir, 0o755); err != nil {
t.Fatalf("mkdir dst: %v", err)
}
stats, err := CopyStats(srcFile)
if err != nil {
t.Fatalf("copy stats: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = MovePathWithProgressContext(ctx, srcFile, dstDir, false, stats, nil)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context cancellation, got %v", err)
}
if _, statErr := os.Stat(srcFile); statErr != nil {
t.Fatalf("expected source to remain in place, stat err=%v", statErr)
}
targetPath := filepath.Join(dstDir, filepath.Base(srcFile))
if _, statErr := os.Stat(targetPath); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("expected destination file to be absent, stat err=%v", statErr)
}
}
func TestRenamePath(t *testing.T) {
t.Parallel()
root := t.TempDir()
source := filepath.Join(root, "old.txt")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
target, err := RenamePath(source, "new.txt")
if err != nil {
t.Fatalf("rename: %v", err)
}
if filepath.Base(target) != "new.txt" {
t.Fatalf("unexpected target path: %s", target)
}
if _, statErr := os.Stat(target); statErr != nil {
t.Fatalf("expected renamed file to exist, stat err=%v", statErr)
}
if _, statErr := os.Stat(source); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("expected source to be absent, stat err=%v", statErr)
}
}
func TestRenamePathRejectsExistingTarget(t *testing.T) {
t.Parallel()
root := t.TempDir()
source := filepath.Join(root, "old.txt")
target := filepath.Join(root, "new.txt")
if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
if err := os.WriteFile(target, []byte("payload"), 0o644); err != nil {
t.Fatalf("write target: %v", err)
}
_, err := RenamePath(source, "new.txt")
if err == nil {
t.Fatalf("expected overwrite error, got nil")
}
if got, want := err.Error(), ErrOverwrite(target).Error(); got != want {
t.Fatalf("expected overwrite error %q, got %q", want, got)
}
}

View file

@ -0,0 +1,693 @@
package vfs
import (
"bytes"
"encoding/json"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
)
var sgrRegexp = regexp.MustCompile(`\x1b\[([0-9;:]*)m`)
var sgrNumberRegexp = regexp.MustCompile(`\d+`)
type PreviewKind string
const (
PreviewKindEmpty PreviewKind = "empty"
PreviewKindDirectory PreviewKind = "directory"
PreviewKindText PreviewKind = "text"
PreviewKindImage PreviewKind = "image"
PreviewKindPDF PreviewKind = "pdf"
PreviewKindAudio PreviewKind = "audio"
PreviewKindVideo PreviewKind = "video"
PreviewKindBinary PreviewKind = "binary"
PreviewKindError PreviewKind = "error"
)
type Metadata struct {
Path string
Kind string
Size int64
SizeKnown bool
ModifiedAt string
CreatedAt string
Permissions string
ImageFormat string
ImageSize string
Extension string
// Extended preview metadata
Duration string
Bitrate string
AudioCodec string
VideoCodec string
SampleRate string
Channels string
PageCount string
Dimensions string
}
type Preview struct {
Kind PreviewKind
Title string
Body string
PlainBody string
Metadata Metadata
Entries []Entry
}
type PreviewOptions struct {
ShowHidden bool
DirsFirst bool
SortBy string
SortReverse bool
MaxPreviewBytes int64
DirectoryPreviewLimit int
HumanReadableSize bool
ThemeName string
UseNerdIcons bool
ImagePreviewWidth int
ImagePreviewHeight int
}
func BuildPreview(entry Entry, options PreviewOptions) Preview {
preview := Preview{
Kind: PreviewKindEmpty,
Title: entry.DisplayName(),
Metadata: Metadata{
Path: entry.Path,
Kind: kindLabel(entry),
Permissions: Permissions(entry.Mode),
ModifiedAt: ShortTime(entry.ModifiedAt),
CreatedAt: "n/a",
Extension: entry.Extension,
},
}
if entry.CreatedKnown {
preview.Metadata.CreatedAt = ShortTime(entry.CreatedAt)
}
if entry.IsDir {
preview.Kind = PreviewKindDirectory
preview.Metadata.Size = entry.Size
preview.Metadata.SizeKnown = entry.DirSizeKnown
preview.Body, preview.Entries = buildDirectoryPreview(entry.Path, options)
preview.PlainBody = preview.Body
return preview
}
preview.Metadata.Size = entry.Size
preview.Metadata.SizeKnown = true
file, err := os.Open(entry.Path)
if err != nil {
preview.Kind = PreviewKindError
preview.Body = fmt.Sprintf("Could not open file:\n\n%s", err)
preview.PlainBody = preview.Body
return preview
}
defer file.Close()
buffer := new(bytes.Buffer)
if _, err := io.CopyN(buffer, file, options.MaxPreviewBytes); err != nil && err != io.EOF {
preview.Kind = PreviewKindError
preview.Body = fmt.Sprintf("Could not read preview:\n\n%s", err)
preview.PlainBody = preview.Body
return preview
}
data := buffer.Bytes()
if format, dimensions, ok := DetectImage(data); ok {
preview.Kind = PreviewKindImage
preview.Metadata.ImageFormat = format
preview.Metadata.ImageSize = dimensions
inline := renderImageInlinePreview(entry.Path, options.ImagePreviewWidth, options.ImagePreviewHeight)
if inline != "" {
preview.Body = inline
}
preview.PlainBody = preview.Body
return preview
}
// Extended preview for PDF, audio, video via external utilities
if hasExt(pdfExtensions, entry.Extension) {
return buildPDFPreview(entry, options, preview)
}
if hasExt(audioExtensions, entry.Extension) {
return buildAudioPreview(entry, options, preview)
}
if hasExt(videoExtensions, entry.Extension) {
return buildVideoPreview(entry, options, preview)
}
if IsBinarySample(data) {
preview.Kind = PreviewKindBinary
preview.Body = "Binary file detected.\n\nSafe inline preview is disabled for this file type."
preview.PlainBody = preview.Body
return preview
}
preview.Kind = PreviewKindText
preview.PlainBody = strings.ReplaceAll(string(data), "\t", " ")
preview.Body = highlightText(entry.Path, preview.PlainBody, options.ThemeName)
return preview
}
func highlightText(path string, source string, themeName string) string {
lexer := lexers.Match(path)
if lexer == nil {
lexer = lexers.Analyse(source)
}
if lexer == nil {
return source
}
iterator, err := chroma.Coalesce(lexer).Tokenise(nil, source)
if err != nil {
return source
}
style := styles.Get(chromaStyleName(themeName))
if style == nil {
return source
}
style = styleWithoutBackground(style)
if style == nil {
return source
}
var output bytes.Buffer
if err := formatters.TTY16m.Format(&output, style, iterator); err != nil {
return source
}
return stripBackgroundSGR(output.String())
}
func styleWithoutBackground(base *chroma.Style) *chroma.Style {
if base == nil {
return nil
}
builder := base.Builder().Transform(func(entry chroma.StyleEntry) chroma.StyleEntry {
entry.Background = 0
return entry
})
stripped, err := builder.Build()
if err != nil {
return base
}
return stripped
}
func stripBackgroundSGR(text string) string {
return sgrRegexp.ReplaceAllStringFunc(text, func(seq string) string {
matches := sgrRegexp.FindStringSubmatch(seq)
if len(matches) != 2 {
return seq
}
filtered := filterSGRParams(matches[1])
if filtered == "" {
return ""
}
return "\x1b[" + filtered + "m"
})
}
func filterSGRParams(paramString string) string {
if paramString == "" {
return ""
}
raw := sgrNumberRegexp.FindAllString(paramString, -1)
if len(raw) == 0 {
return ""
}
codes := make([]int, 0, len(raw))
for _, token := range raw {
value, err := strconv.Atoi(token)
if err != nil {
continue
}
codes = append(codes, value)
}
kept := make([]string, 0, len(codes))
for i := 0; i < len(codes); i++ {
code := codes[i]
if code == 0 {
// Do not hard-reset background to terminal default.
// Reset common text attributes + foreground only.
kept = append(kept, "39", "22", "23", "24", "59")
continue
}
if code == 49 || code == 7 || code == 27 || (code >= 40 && code <= 47) || (code >= 100 && code <= 107) {
continue
}
switch code {
case 48:
// Background color payloads:
// 48;5;n or 48;2;r;g;b (also appears in ':' form; parsed as the same int stream).
if i+1 < len(codes) {
mode := codes[i+1]
switch mode {
case 5:
i += 2
case 2:
i += 4
default:
i++
}
}
continue
case 38, 58:
// Preserve foreground (38) and underline color (58) payloads.
kept = append(kept, strconv.Itoa(code))
if i+1 < len(codes) {
mode := codes[i+1]
kept = append(kept, strconv.Itoa(mode))
switch mode {
case 5:
if i+2 < len(codes) {
kept = append(kept, strconv.Itoa(codes[i+2]))
}
i += 2
case 2:
if i+4 < len(codes) {
kept = append(kept,
strconv.Itoa(codes[i+2]),
strconv.Itoa(codes[i+3]),
strconv.Itoa(codes[i+4]),
)
}
i += 4
default:
i++
}
}
continue
}
kept = append(kept, strconv.Itoa(code))
}
return strings.Join(kept, ";")
}
func chromaStyleName(themeName string) string {
switch strings.ToLower(strings.TrimSpace(themeName)) {
case "catppuccin-mocha", "catppuccin-lavender":
return "catppuccin-mocha"
case "tokyo-night":
return "tokyonight-night"
case "gruvbox-dark", "gruvbox":
return "gruvbox"
case "nord-frost", "nord":
return "nord"
case "dracula":
return "dracula"
case "rose-pine":
return "rose-pine"
case "solarized-dark":
return "solarized-dark"
default:
return "catppuccin-mocha"
}
}
func buildDirectoryPreview(path string, options PreviewOptions) (string, []Entry) {
entries, err := ListDir(path, ListOptions{
ShowHidden: options.ShowHidden,
DirsFirst: options.DirsFirst,
SortBy: options.SortBy,
SortReverse: options.SortReverse,
})
if err != nil {
return fmt.Sprintf("Could not list directory:\n\n%s", err), nil
}
if len(entries) == 0 {
return "Directory is empty.", nil
}
// Return all entries as-is for column-based rendering.
// The text body is still generated for terminals that don't support
// the rich rendering, and as a fallback.
var lines []string
for _, entry := range entries {
if entry.IsParent {
continue
}
icon := previewIcon(entry, options.UseNerdIcons)
size := ""
if !entry.IsDir {
if options.HumanReadableSize {
size = HumanSize(entry.Size)
} else {
size = fmt.Sprintf("%d", entry.Size)
}
}
lines = append(lines, fmt.Sprintf("%s %-36s %12s %s", icon, entry.DisplayName(), size, ShortTime(entry.ModifiedAt)))
if len(lines) >= options.DirectoryPreviewLimit {
lines = append(lines, "…")
break
}
}
return strings.Join(lines, "\n"), entries
}
func previewIcon(entry Entry, useNerdIcons bool) string {
if !useNerdIcons {
switch entry.Category() {
case "directory":
return "[D]"
case "config":
return "[C]"
case "text":
return "[T]"
case "image":
return "[I]"
case "executable":
return "[X]"
case "archive":
return "[A]"
default:
return "[F]"
}
}
switch entry.Category() {
case "directory":
return ""
case "config":
return ""
case "text":
return "󰈙"
case "image":
return "󰋩"
case "executable":
return "󰆍"
case "archive":
return ""
default:
return "󰈔"
}
}
func renderImageInlinePreview(path string, width int, height int) string {
return ""
}
func findTool(name string) string {
path, err := exec.LookPath(name)
if err != nil {
return ""
}
return path
}
func buildPDFPreview(entry Entry, options PreviewOptions, base Preview) Preview {
pdftotext := findTool("pdftotext")
if pdftotext == "" {
base.Kind = PreviewKindBinary
base.Body = "PDF file detected.\nInstall poppler-utils (pdftotext) for text preview."
base.PlainBody = base.Body
return base
}
cmd := exec.Command(pdftotext, "-layout", "-nopgbrk", entry.Path, "-")
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("pdftotext error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
text := string(out)
if int64(len(text)) > options.MaxPreviewBytes {
text = text[:options.MaxPreviewBytes]
}
// Get page count via pdfinfo if available
pdfinfo := findTool("pdfinfo")
if pdfinfo != "" {
infoCmd := exec.Command(pdfinfo, entry.Path)
if infoOut, err := infoCmd.Output(); err == nil {
for _, line := range strings.Split(string(infoOut), "\n") {
if strings.HasPrefix(strings.ToLower(line), "pages:") {
base.Metadata.PageCount = strings.TrimSpace(strings.TrimPrefix(line[5:], ":"))
break
}
}
}
}
base.Kind = PreviewKindPDF
base.PlainBody = text
base.Body = highlightText(entry.Path, text, options.ThemeName)
return base
}
func buildAudioPreview(entry Entry, options PreviewOptions, base Preview) Preview {
ffprobe := findTool("ffprobe")
if ffprobe == "" {
base.Kind = PreviewKindBinary
base.Body = "Audio file detected.\nInstall ffmpeg (ffprobe) for metadata preview."
base.PlainBody = base.Body
return base
}
cmd := exec.Command(ffprobe,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
entry.Path,
)
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("ffprobe error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
var info struct {
Format struct {
Duration string `json:"duration"`
Bitrate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
SampleRate string `json:"sample_rate"`
Channels int `json:"channels"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &info); err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("Could not parse ffprobe output:\n\n%s", err)
base.PlainBody = base.Body
return base
}
// Format duration
if info.Format.Duration != "" {
if secs, err := strconv.ParseFloat(info.Format.Duration, 64); err == nil {
mins := int(secs) / 60
secsRem := int(secs) % 60
base.Metadata.Duration = fmt.Sprintf("%d:%02d", mins, secsRem)
}
}
if info.Format.Bitrate != "" {
if bps, err := strconv.ParseInt(info.Format.Bitrate, 10, 64); err == nil {
base.Metadata.Bitrate = fmt.Sprintf("%d kbps", bps/1000)
}
}
for _, stream := range info.Streams {
if stream.CodecType == "audio" {
base.Metadata.AudioCodec = stream.CodecName
if stream.SampleRate != "" {
base.Metadata.SampleRate = stream.SampleRate + " Hz"
}
switch stream.Channels {
case 1:
base.Metadata.Channels = "mono"
case 2:
base.Metadata.Channels = "stereo"
case 6:
base.Metadata.Channels = "5.1"
case 8:
base.Metadata.Channels = "7.1"
default:
base.Metadata.Channels = fmt.Sprintf("%d ch", stream.Channels)
}
break
}
}
var lines []string
lines = append(lines, fmt.Sprintf(" Duration: %s", base.Metadata.Duration))
if base.Metadata.Bitrate != "" {
lines = append(lines, fmt.Sprintf(" Bitrate: %s", base.Metadata.Bitrate))
}
if base.Metadata.AudioCodec != "" {
lines = append(lines, fmt.Sprintf(" Codec: %s", base.Metadata.AudioCodec))
}
if base.Metadata.SampleRate != "" {
lines = append(lines, fmt.Sprintf(" Rate: %s", base.Metadata.SampleRate))
}
if base.Metadata.Channels != "" {
lines = append(lines, fmt.Sprintf(" Channels: %s", base.Metadata.Channels))
}
base.Kind = PreviewKindAudio
base.Body = fmt.Sprintf("🎵 Audio File\n\n%s", strings.Join(lines, "\n"))
base.PlainBody = fmt.Sprintf("Audio File\n\nDuration: %s\nBitrate: %s\nCodec: %s\nRate: %s\nChannels: %s",
base.Metadata.Duration, base.Metadata.Bitrate, base.Metadata.AudioCodec,
base.Metadata.SampleRate, base.Metadata.Channels)
return base
}
func buildVideoPreview(entry Entry, options PreviewOptions, base Preview) Preview {
ffprobe := findTool("ffprobe")
if ffprobe == "" {
base.Kind = PreviewKindBinary
base.Body = "Video file detected.\nInstall ffmpeg (ffprobe) for metadata preview."
base.PlainBody = base.Body
return base
}
cmd := exec.Command(ffprobe,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
entry.Path,
)
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("ffprobe error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
var info struct {
Format struct {
Duration string `json:"duration"`
Bitrate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &info); err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("Could not parse ffprobe output:\n\n%s", err)
base.PlainBody = base.Body
return base
}
// Format duration
if info.Format.Duration != "" {
if secs, err := strconv.ParseFloat(info.Format.Duration, 64); err == nil {
hrs := int(secs) / 3600
mins := (int(secs) % 3600) / 60
secsRem := int(secs) % 60
if hrs > 0 {
base.Metadata.Duration = fmt.Sprintf("%d:%02d:%02d", hrs, mins, secsRem)
} else {
base.Metadata.Duration = fmt.Sprintf("%d:%02d", mins, secsRem)
}
}
}
if info.Format.Bitrate != "" {
if bps, err := strconv.ParseInt(info.Format.Bitrate, 10, 64); err == nil {
base.Metadata.Bitrate = fmt.Sprintf("%d kbps", bps/1000)
}
}
for _, stream := range info.Streams {
switch stream.CodecType {
case "video":
base.Metadata.VideoCodec = stream.CodecName
if stream.Width > 0 && stream.Height > 0 {
base.Metadata.Dimensions = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
}
case "audio":
if base.Metadata.AudioCodec == "" {
base.Metadata.AudioCodec = stream.CodecName
}
}
}
var lines []string
lines = append(lines, fmt.Sprintf(" Duration: %s", base.Metadata.Duration))
if base.Metadata.Bitrate != "" {
lines = append(lines, fmt.Sprintf(" Bitrate: %s", base.Metadata.Bitrate))
}
if base.Metadata.VideoCodec != "" {
lines = append(lines, fmt.Sprintf(" Video: %s", base.Metadata.VideoCodec))
}
if base.Metadata.Dimensions != "" {
lines = append(lines, fmt.Sprintf(" Resolution: %s", base.Metadata.Dimensions))
}
if base.Metadata.AudioCodec != "" {
lines = append(lines, fmt.Sprintf(" Audio: %s", base.Metadata.AudioCodec))
}
base.Kind = PreviewKindVideo
base.Body = fmt.Sprintf("🎬 Video File\n\n%s", strings.Join(lines, "\n"))
base.PlainBody = fmt.Sprintf("Video File\n\nDuration: %s\nBitrate: %s\nVideo: %s\nResolution: %s\nAudio: %s",
base.Metadata.Duration, base.Metadata.Bitrate, base.Metadata.VideoCodec,
base.Metadata.Dimensions, base.Metadata.AudioCodec)
return base
}
func DetectImage(data []byte) (string, string, bool) {
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return "", "", false
}
return format, fmt.Sprintf("%dx%d", cfg.Width, cfg.Height), true
}
func kindLabel(entry Entry) string {
switch {
case entry.IsParent:
return "parent"
case entry.IsDir:
return "directory"
case entry.Extension != "":
return "file"
default:
return strings.TrimPrefix(filepath.Ext(entry.Name), ".")
}
}

View file

@ -0,0 +1,751 @@
package remote
import (
"bufio"
"context"
"fmt"
"io"
"net"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)
// SSHClient wraps an SSH connection and SFTP client for remote filesystem access.
type SSHClient struct {
// Host is the SSH host configuration used to establish the connection.
Host SSHHost
sshConn *ssh.Client
sftpCli *sftp.Client
keepaliveStop chan struct{}
keepaliveWg sync.WaitGroup
}
// Connect establishes an SSH connection to the remote host and opens an SFTP session.
// It uses key-based authentication if IdentityFile is set, otherwise falls back to password auth.
func Connect(host SSHHost) (*SSHClient, error) {
authMethods, err := authMethodsForHost(host)
if err != nil {
return nil, fmt.Errorf("ssh auth: %w", err)
}
user := host.User
if user == "" {
user = os.Getenv("USER")
}
config := &ssh.ClientConfig{
User: user,
Auth: authMethods,
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: support known_hosts verification
Timeout: 15 * time.Second,
}
addr := host.Addr()
sshConn, err := ssh.Dial("tcp", addr, config)
if err != nil {
return nil, fmt.Errorf("ssh dial %s: %w", addr, err)
}
sftpCli, err := sftp.NewClient(sshConn)
if err != nil {
sshConn.Close()
return nil, fmt.Errorf("sftp client: %w", err)
}
client := &SSHClient{
Host: host,
sshConn: sshConn,
sftpCli: sftpCli,
keepaliveStop: make(chan struct{}),
}
// Start keepalive goroutine — sends keepalive@openssh.com every 30s
// to prevent the SSH server from dropping the connection during inactivity.
client.keepaliveWg.Add(1)
go func() {
defer client.keepaliveWg.Done()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
_, _, err := sshConn.SendRequest("keepalive@openssh.com", true, nil)
if err != nil {
return
}
case <-client.keepaliveStop:
return
}
}
}()
return client, nil
}
// authMethodsForHost returns the appropriate SSH auth methods for the given host.
// For SSH config hosts with IdentityFile, it uses public key authentication.
// For custom hosts with a password, it uses password authentication.
func authMethodsForHost(host SSHHost) ([]ssh.AuthMethod, error) {
var methods []ssh.AuthMethod
// Try key-based auth if identity file is specified
if host.IdentityFile != "" {
key, err := os.ReadFile(host.IdentityFile)
if err == nil {
signer, err := ssh.ParsePrivateKey(key)
if err == nil {
methods = append(methods, ssh.PublicKeys(signer))
} else {
// If the key is encrypted, try with empty passphrase or common ones
// For simplicity, we try without passphrase first
// In a real implementation, we might prompt for a passphrase
}
}
}
// Try password auth if password is set
if host.Password != "" {
methods = append(methods, ssh.Password(host.Password))
}
// Always include keyboard-interactive as a fallback (it wraps password)
if host.Password != "" {
methods = append(methods, ssh.KeyboardInteractive(
func(user, instruction string, questions []string, echos []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range questions {
answers[i] = host.Password
}
return answers, nil
},
))
}
// Always try default SSH agent and default keys as a last resort
// This covers the case where the user has an SSH agent running with loaded keys
// but no IdentityFile is specified in the config.
if host.IdentityFile == "" && host.Password == "" {
// Add default key paths
home, err := os.UserHomeDir()
if err == nil {
defaultKeys := []string{
home + "/.ssh/id_rsa",
home + "/.ssh/id_ed25519",
home + "/.ssh/id_ecdsa",
home + "/.ssh/id_ecdsa_sk",
home + "/.ssh/id_ed25519_sk",
home + "/.ssh/identity",
}
for _, keyPath := range defaultKeys {
if key, err := os.ReadFile(keyPath); err == nil {
if signer, err := ssh.ParsePrivateKey(key); err == nil {
methods = append(methods, ssh.PublicKeys(signer))
}
}
}
}
}
if len(methods) == 0 {
return nil, fmt.Errorf("no authentication methods available for host %q", host.Name)
}
return methods, nil
}
// ReadDir reads the contents of a remote directory and returns os.FileInfo entries.
func (c *SSHClient) ReadDir(dirPath string) ([]os.FileInfo, error) {
if c.sftpCli == nil {
return nil, fmt.Errorf("not connected")
}
return c.sftpCli.ReadDir(dirPath)
}
// Lstat returns file information without following symlinks.
func (c *SSHClient) Lstat(path string) (os.FileInfo, error) {
if c.sftpCli == nil {
return nil, fmt.Errorf("not connected")
}
return c.sftpCli.Lstat(path)
}
// Stat returns file information following symlinks.
func (c *SSHClient) Stat(path string) (os.FileInfo, error) {
if c.sftpCli == nil {
return nil, fmt.Errorf("not connected")
}
return c.sftpCli.Stat(path)
}
// ReadLink reads the target of a symbolic link.
func (c *SSHClient) ReadLink(linkPath string) (string, error) {
if c.sftpCli == nil {
return "", fmt.Errorf("not connected")
}
return c.sftpCli.ReadLink(linkPath)
}
// RealPath resolves a path to its absolute form on the remote server.
func (c *SSHClient) RealPath(p string) (string, error) {
if c.sftpCli == nil {
return "", fmt.Errorf("not connected")
}
return c.sftpCli.RealPath(p)
}
// ReadFile opens a remote file for reading.
func (c *SSHClient) ReadFile(filePath string) (io.ReadCloser, error) {
if c.sftpCli == nil {
return nil, fmt.Errorf("not connected")
}
return c.sftpCli.Open(filePath)
}
// CreateFile opens a remote file for writing, creating it if it doesn't exist.
func (c *SSHClient) CreateFile(filePath string) (io.WriteCloser, error) {
if c.sftpCli == nil {
return nil, fmt.Errorf("not connected")
}
return c.sftpCli.Create(filePath)
}
// MkdirAll creates a remote directory and any necessary parents.
// If the directory already exists, it returns nil (no error).
func (c *SSHClient) MkdirAll(dirPath string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
// sftp doesn't have MkdirAll, so we implement it manually
// First check if the path already exists
_, err := c.sftpCli.Stat(dirPath)
if err == nil {
return nil // already exists
}
if !os.IsNotExist(err) {
return err
}
// Ensure parent exists first
parent := path.Dir(dirPath)
if parent != dirPath && parent != "." {
if err := c.MkdirAll(parent); err != nil {
return err
}
}
return c.sftpCli.Mkdir(dirPath)
}
// Mkdir creates a single remote directory.
func (c *SSHClient) Mkdir(dirPath string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
return c.sftpCli.Mkdir(dirPath)
}
// Remove deletes a remote file.
func (c *SSHClient) Remove(filePath string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
return c.sftpCli.Remove(filePath)
}
// RemoveDirectory removes a remote directory (must be empty).
func (c *SSHClient) RemoveDirectory(dirPath string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
return c.sftpCli.RemoveDirectory(dirPath)
}
// Rename moves/renames a remote file or directory.
func (c *SSHClient) Rename(oldPath, newPath string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
return c.sftpCli.Rename(oldPath, newPath)
}
// Close closes the SFTP session and SSH connection.
func (c *SSHClient) Close() error {
// Stop the keepalive goroutine first
if c.keepaliveStop != nil {
select {
case <-c.keepaliveStop:
// already closed
default:
close(c.keepaliveStop)
}
c.keepaliveWg.Wait()
}
var firstErr error
if c.sftpCli != nil {
if err := c.sftpCli.Close(); err != nil {
firstErr = err
}
c.sftpCli = nil
}
if c.sshConn != nil {
if err := c.sshConn.Close(); err != nil && firstErr == nil {
firstErr = err
}
c.sshConn = nil
}
return firstErr
}
// IsConnected returns true if the client has an active connection.
func (c *SSHClient) IsConnected() bool {
return c.sftpCli != nil && c.sshConn != nil
}
// Exec runs a shell command on the remote server and returns combined output.
func (c *SSHClient) Exec(cmd string) ([]byte, error) {
if c.sshConn == nil {
return nil, fmt.Errorf("not connected")
}
session, err := c.sshConn.NewSession()
if err != nil {
return nil, fmt.Errorf("open session: %w", err)
}
defer session.Close()
return session.CombinedOutput(cmd)
}
// ExecWithProgress runs a shell command on the remote server and calls onLine
// for each line of stdout output.
func (c *SSHClient) ExecWithProgress(cmd string, onLine func(line string)) error {
if c.sshConn == nil {
return fmt.Errorf("not connected")
}
session, err := c.sshConn.NewSession()
if err != nil {
return fmt.Errorf("open session: %w", err)
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := session.Start(cmd); err != nil {
return fmt.Errorf("start command: %w", err)
}
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
onLine(scanner.Text())
}
if scanErr := scanner.Err(); scanErr != nil {
return scanErr
}
return session.Wait()
}
// SameHostAs returns true if this client and other are connected to the same server.
func (c *SSHClient) SameHostAs(other *SSHClient) bool {
if c == nil || other == nil {
return false
}
return c.Host.SameAs(other.Host)
}
// RemoveRecursive recursively deletes a remote file or directory.
// For directories, it walks and removes all children first.
func (c *SSHClient) RemoveRecursive(path string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
info, err := c.sftpCli.Stat(path)
if err != nil {
return err
}
if !info.IsDir() {
return c.sftpCli.Remove(path)
}
// Walk directory and collect all paths (files first, then dirs)
var files []string
var dirs []string
err = c.Walk(path, func(walkPath string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if walkPath == path {
return nil // skip root
}
if info.IsDir() {
dirs = append(dirs, walkPath)
} else {
files = append(files, walkPath)
}
return nil
})
if err != nil {
return err
}
// Remove files first, then directories (reverse order for deepest first)
for _, f := range files {
if err := c.sftpCli.Remove(f); err != nil {
return err
}
}
for i := len(dirs) - 1; i >= 0; i-- {
if err := c.sftpCli.RemoveDirectory(dirs[i]); err != nil {
return err
}
}
// Finally remove the root directory
return c.sftpCli.RemoveDirectory(path)
}
// CopyFileToRemote copies a local file to a remote destination via SFTP.
// It creates parent directories as needed.
func (c *SSHClient) CopyFileToRemote(localPath, remotePath string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
localFile, err := os.Open(localPath)
if err != nil {
return fmt.Errorf("open local: %w", err)
}
defer localFile.Close()
// Ensure parent directory exists
parent := path.Dir(remotePath)
if err := c.MkdirAll(parent); err != nil {
return fmt.Errorf("mkdir remote: %w", err)
}
remoteFile, err := c.sftpCli.Create(remotePath)
if err != nil {
return fmt.Errorf("create remote: %w", err)
}
defer remoteFile.Close()
_, err = io.Copy(remoteFile, localFile)
if err != nil {
return fmt.Errorf("copy to remote: %w", err)
}
return nil
}
// CopyFileFromRemote copies a remote file to a local destination via SFTP.
// It creates parent directories as needed.
func (c *SSHClient) CopyFileFromRemote(remotePath, localPath string) error {
if c.sftpCli == nil {
return fmt.Errorf("not connected")
}
remoteFile, err := c.sftpCli.Open(remotePath)
if err != nil {
return fmt.Errorf("open remote: %w", err)
}
defer remoteFile.Close()
// Ensure parent directory exists
parent := filepath.Dir(localPath)
if err := os.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("mkdir local: %w", err)
}
localFile, err := os.Create(localPath)
if err != nil {
return fmt.Errorf("create local: %w", err)
}
defer localFile.Close()
_, err = io.Copy(localFile, remoteFile)
if err != nil {
return fmt.Errorf("copy from remote: %w", err)
}
return nil
}
// DownloadFile downloads a remote file to a local path via SFTP.
func (c *SSHClient) DownloadFile(remotePath, localPath string) error {
return c.CopyFileFromRemote(remotePath, localPath)
}
// CopyDirToRemote recursively copies a local directory to a remote path.
func (c *SSHClient) CopyDirToRemote(localDir, remoteDir string) error {
return c.copyDirToRemote(localDir, remoteDir, nil, nil)
}
// CopyDirToRemoteProgress is like CopyDirToRemote but calls onFile after each copy.
func (c *SSHClient) CopyDirToRemoteProgress(localDir, remoteDir string, onFile func(path string, done, total int), ctx context.Context) error {
return c.copyDirToRemote(localDir, remoteDir, onFile, ctx)
}
func (c *SSHClient) copyDirToRemote(localDir, remoteDir string, onFile func(path string, done, total int), ctx context.Context) error {
done := 0
return filepath.Walk(localDir, func(localPath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if ctx != nil {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
relPath, _ := filepath.Rel(localDir, localPath)
remotePath := path.Join(remoteDir, relPath)
if info.IsDir() {
return c.MkdirAll(remotePath)
}
if err := c.CopyFileToRemote(localPath, remotePath); err != nil {
return err
}
done++
if onFile != nil {
onFile(remotePath, done, 0)
}
return nil
})
}
// CopyDirFromRemote recursively copies a remote directory to a local path.
func (c *SSHClient) CopyDirFromRemote(remoteDir, localDir string) error {
return c.copyDirFromRemote(remoteDir, localDir, nil, nil)
}
// CopyDirFromRemoteProgress is like CopyDirFromRemote but calls onFile after each copy.
func (c *SSHClient) CopyDirFromRemoteProgress(remoteDir, localDir string, onFile func(path string, done, total int), ctx context.Context) error {
return c.copyDirFromRemote(remoteDir, localDir, onFile, ctx)
}
func (c *SSHClient) copyDirFromRemote(remoteDir, localDir string, onFile func(path string, done, total int), ctx context.Context) error {
done := 0
return c.Walk(remoteDir, func(remotePath string, info os.FileInfo, err error) error {
if ctx != nil {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
if err != nil {
return err
}
relPath, _ := filepath.Rel(remoteDir, remotePath)
localPath := filepath.Join(localDir, relPath)
if info.IsDir() {
return os.MkdirAll(localPath, 0o755)
}
if err := c.CopyFileFromRemote(remotePath, localPath); err != nil {
return err
}
done++
if onFile != nil {
onFile(localPath, done, 0)
}
return nil
})
}
// CopyFileBetweenRemotes copies a single file from one remote host to another
// by streaming the file contents through the local machine. Both SFTP connections
// must be active (connected).
func CopyFileBetweenRemotes(srcClient, dstClient *SSHClient, srcPath, dstPath string) error {
if srcClient.sftpCli == nil {
return fmt.Errorf("source client not connected")
}
if dstClient.sftpCli == nil {
return fmt.Errorf("destination client not connected")
}
srcFile, err := srcClient.sftpCli.Open(srcPath)
if err != nil {
return fmt.Errorf("open remote source %s: %w", srcPath, err)
}
defer srcFile.Close()
// Ensure parent directory exists on the destination
parent := path.Dir(dstPath)
if err := dstClient.MkdirAll(parent); err != nil {
return fmt.Errorf("mkdir remote dest %s: %w", parent, err)
}
dstFile, err := dstClient.sftpCli.Create(dstPath)
if err != nil {
return fmt.Errorf("create remote dest %s: %w", dstPath, err)
}
defer dstFile.Close()
_, err = io.Copy(dstFile, srcFile)
if err != nil {
return fmt.Errorf("copy remote to remote %s → %s: %w", srcPath, dstPath, err)
}
return nil
}
// CopyDirBetweenRemotes recursively copies a directory from one remote host to another.
func CopyDirBetweenRemotes(srcClient, dstClient *SSHClient, srcDir, dstDir string) error {
return copyDirBetweenRemotes(srcClient, dstClient, srcDir, dstDir, nil, nil)
}
func copyDirBetweenRemotes(srcClient, dstClient *SSHClient, srcDir, dstDir string, onFile func(path string, done, total int), ctx context.Context) error {
done := 0
return srcClient.Walk(srcDir, func(remotePath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if ctx != nil {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
relPath, _ := filepath.Rel(srcDir, remotePath)
dstPath := path.Join(dstDir, relPath)
if info.IsDir() {
return dstClient.MkdirAll(dstPath)
}
if err := CopyFileBetweenRemotes(srcClient, dstClient, remotePath, dstPath); err != nil {
return err
}
done++
if onFile != nil {
onFile(remotePath, done, 0)
}
return nil
})
}
// CopyDirBetweenRemotesProgress is like CopyDirBetweenRemotes with progress and context support.
func CopyDirBetweenRemotesProgress(srcClient, dstClient *SSHClient, srcDir, dstDir string, onFile func(path string, done, total int), ctx context.Context) error {
return copyDirBetweenRemotes(srcClient, dstClient, srcDir, dstDir, onFile, ctx)
}
// Walk walks the remote filesystem tree rooted at root, calling walkFn for each file/dir.
// This is a simplified version of filepath.Walk for SFTP.
type walkFunc func(path string, info os.FileInfo, err error) error
func (c *SSHClient) Walk(root string, walkFn walkFunc) error {
return c.walk(root, walkFn, nil)
}
func (c *SSHClient) walk(dirPath string, walkFn walkFunc, info os.FileInfo) error {
if info == nil {
var err error
info, err = c.sftpCli.Stat(dirPath)
if err != nil {
return walkFn(dirPath, nil, err)
}
}
err := walkFn(dirPath, info, nil)
if err != nil {
if err == filepathSkipDir {
return nil
}
return err
}
if !info.IsDir() {
return nil
}
entries, err := c.sftpCli.ReadDir(dirPath)
if err != nil {
return walkFn(dirPath, info, err)
}
for _, entry := range entries {
childPath := path.Join(dirPath, entry.Name())
if entry.IsDir() {
err = c.walk(childPath, walkFn, entry)
} else {
err = walkFn(childPath, entry, nil)
}
if err != nil {
return err
}
}
return nil
}
// filepathSkipDir is used as a return value from Walk to skip a directory.
var filepathSkipDir = fmt.Errorf("skip this directory")
// DirectorySize recursively walks a remote directory and sums up file sizes.
func (c *SSHClient) DirectorySize(dirPath string) (int64, error) {
var total int64
err := c.Walk(dirPath, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
if err != nil {
return 0, err
}
return total, nil
}
// SftpToFileInfo converts an os.FileInfo to a vfs-compatible file info.
// This is used for consistent file information handling across local and remote.
func SftpToFileInfo(name string, info os.FileInfo) (os.FileInfo, error) {
return info, nil
}
// WalkDirEntry wraps os.FileInfo with the file name for directory listings.
type WalkDirEntry struct {
os.FileInfo
entryName string
}
func (e *WalkDirEntry) Name() string {
return e.entryName
}
// NewWalkDirEntry creates a new WalkDirEntry with an overridden name.
func NewWalkDirEntry(info os.FileInfo, name string) *WalkDirEntry {
return &WalkDirEntry{FileInfo: info, entryName: name}
}
// DialTimeout is the timeout for establishing SSH connections.
const DialTimeout = 15 * time.Second
// DefaultPort is the default SSH port.
const DefaultPort = "22"
// ResolveAddr returns the SSH address for the given host, applying the default port if needed.
func ResolveAddr(hostname, port string) string {
host := strings.TrimSpace(hostname)
if port == "" || port == "0" {
port = DefaultPort
}
return net.JoinHostPort(host, port)
}

View file

@ -0,0 +1,209 @@
package remote
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
// ParseSSHConfig parses ~/.ssh/config and returns a list of SSH hosts.
// It handles the most common SSH config directives: Host, HostName, Port, User, IdentityFile.
func ParseSSHConfig() []SSHHost {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
configPath := filepath.Join(home, ".ssh", "config")
return parseSSHConfigFile(configPath)
}
func parseSSHConfigFile(path string) []SSHHost {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var hosts []SSHHost
var current *SSHHost
var currentNames []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Skip empty lines and comments
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Remove inline comments (everything after # that's not in quotes)
if idx := strings.Index(line, "#"); idx >= 0 {
line = strings.TrimSpace(line[:idx])
if line == "" {
continue
}
}
parts := strings.Fields(line)
if len(parts) < 2 {
continue
}
keyword := strings.ToLower(parts[0])
value := strings.Join(parts[1:], " ")
switch keyword {
case "host":
// Save previous host block
if current != nil && len(currentNames) > 0 {
for _, name := range currentNames {
if !isWildcardPattern(name) {
host := *current
host.Name = name
hosts = append(hosts, host)
}
}
}
// Start new host block
current = &SSHHost{
Port: "22",
FromSSHConfig: true,
}
currentNames = strings.Fields(value)
case "hostname":
if current != nil {
current.HostName = value
}
case "port":
if current != nil {
current.Port = value
}
case "user":
if current != nil {
current.User = value
}
case "identityfile":
if current != nil {
// Handle ~ expansion and relative paths
resolved := resolveIdentityPath(value)
if resolved != "" {
current.IdentityFile = resolved
}
}
}
}
// Save last host block
if current != nil && len(currentNames) > 0 {
for _, name := range currentNames {
if !isWildcardPattern(name) {
host := *current
host.Name = name
hosts = append(hosts, host)
}
}
}
return hosts
}
// isWildcardPattern returns true if the pattern contains wildcard characters.
func isWildcardPattern(pattern string) bool {
return strings.ContainsAny(pattern, "*?")
}
// resolveIdentityPath resolves a path from SSH config (handles ~ and relative paths).
func resolveIdentityPath(path string) string {
if path == "" {
return ""
}
// Handle ~/ or $HOME/
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return path
}
path = filepath.Join(home, path[2:])
}
// Handle relative paths (relative to ~/.ssh/)
if !filepath.IsAbs(path) {
home, err := os.UserHomeDir()
if err != nil {
return path
}
path = filepath.Join(home, ".ssh", path)
}
return filepath.Clean(path)
}
// SSHConfigPath returns the path to the user's SSH config file.
func SSHConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".ssh", "config")
}
// HostsFilePath returns the path to the custom hosts data file.
func HostsFilePath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "vcom", "hosts.dat")
}
// GetSSHDir returns the path to the .ssh directory.
func GetSSHDir() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".ssh")
}
// KnownHostsPath returns the path to known_hosts.
func KnownHostsPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".ssh", "known_hosts")
}
// ConfigFileExists checks if the SSH config file exists.
func ConfigFileExists() bool {
path := SSHConfigPath()
if path == "" {
return false
}
_, err := os.Stat(path)
return err == nil
}
// ValidateHost checks if a host entry has the minimum required fields.
func ValidateHost(host SSHHost) error {
if strings.TrimSpace(host.Name) == "" {
return fmt.Errorf("host name is required")
}
if strings.TrimSpace(host.HostName) == "" {
return fmt.Errorf("hostname/address is required")
}
if strings.TrimSpace(host.User) == "" {
return fmt.Errorf("username is required")
}
return nil
}

View file

@ -0,0 +1,311 @@
package remote
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// SSHHost represents a single SSH host configuration.
type SSHHost struct {
// Name is the host alias (e.g. "myserver").
Name string `json:"name"`
// HostName is the actual hostname or IP address.
HostName string `json:"hostname"`
// Port is the SSH port (default 22).
Port string `json:"port,omitempty"`
// User is the SSH username.
User string `json:"user,omitempty"`
// IdentityFile is the path to the private key file (for key-based auth).
IdentityFile string `json:"identity_file,omitempty"`
// Password is stored encrypted (for password-based auth, user-added hosts).
Password string `json:"password,omitempty"`
// FromSSHConfig indicates this host came from ~/.ssh/config.
FromSSHConfig bool `json:"from_ssh_config"`
}
// DisplayName returns the host display name.
func (h SSHHost) DisplayName() string {
addr := h.HostName
if h.Port != "" && h.Port != "22" {
addr = fmt.Sprintf("%s:%s", addr, h.Port)
}
if h.User != "" {
return fmt.Sprintf("%s (%s@%s)", h.Name, h.User, addr)
}
return fmt.Sprintf("%s (%s)", h.Name, addr)
}
// Addr returns the SSH address string (host:port).
func (h SSHHost) Addr() string {
if h.Port == "" || h.Port == "22" {
return h.HostName + ":22"
}
return h.HostName + ":" + h.Port
}
// SameAs returns true if two hosts point to the same server.
func (h SSHHost) SameAs(other SSHHost) bool {
return h.HostName == other.HostName &&
(h.Port == other.Port || (h.Port == "" && other.Port == "22") || (h.Port == "22" && other.Port == ""))
}
// HostStore manages SSH hosts from both ~/.ssh/config and user-added hosts.
type HostStore struct {
customHosts []SSHHost
configPath string
cipherKey []byte
}
// NewHostStore creates a new HostStore.
func NewHostStore() (*HostStore, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("home dir: %w", err)
}
store := &HostStore{
configPath: filepath.Join(home, ".config", "vcom", "hosts.dat"),
}
// Load or create encryption key
keyPath := filepath.Join(home, ".config", "vcom", ".hosts-key")
store.cipherKey, err = loadOrCreateKey(keyPath)
if err != nil {
return nil, fmt.Errorf("encryption key: %w", err)
}
// Load custom hosts
if err := store.load(); err != nil {
// Ignore load errors for missing file
if !os.IsNotExist(err) {
return nil, err
}
}
return store, nil
}
// loadOrCreateKey loads an existing AES key or creates a new one.
func loadOrCreateKey(path string) ([]byte, error) {
if data, err := os.ReadFile(path); err == nil {
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data)))
if err != nil {
return nil, err
}
if len(key) == 32 {
return key, nil
}
}
// Generate new 32-byte key for AES-256
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("generate key: %w", err)
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("mkdir: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(key)
if err := os.WriteFile(path, []byte(encoded), 0o600); err != nil {
return nil, fmt.Errorf("write key: %w", err)
}
return key, nil
}
type storedHosts struct {
Hosts []storedHost `json:"hosts"`
}
type storedHost struct {
Name string `json:"name"`
HostName string `json:"hostname"`
Port string `json:"port,omitempty"`
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"` // encrypted
IdentityFile string `json:"identity_file,omitempty"`
}
func (s *HostStore) load() error {
data, err := os.ReadFile(s.configPath)
if err != nil {
return err
}
// Decrypt
decrypted, err := decrypt(data, s.cipherKey)
if err != nil {
return fmt.Errorf("decrypt hosts: %w", err)
}
var stored storedHosts
if err := json.Unmarshal(decrypted, &stored); err != nil {
return fmt.Errorf("parse hosts: %w", err)
}
s.customHosts = make([]SSHHost, len(stored.Hosts))
for i, h := range stored.Hosts {
password := ""
if h.Password != "" {
pwd, err := decrypt([]byte(h.Password), s.cipherKey)
if err == nil {
password = string(pwd)
}
}
s.customHosts[i] = SSHHost{
Name: h.Name,
HostName: h.HostName,
Port: h.Port,
User: h.User,
Password: password,
IdentityFile: h.IdentityFile,
FromSSHConfig: false,
}
}
return nil
}
// Save persists custom hosts to disk (encrypted).
func (s *HostStore) Save() error {
stored := storedHosts{
Hosts: make([]storedHost, len(s.customHosts)),
}
for i, h := range s.customHosts {
password := ""
if h.Password != "" {
enc, err := encrypt([]byte(h.Password), s.cipherKey)
if err == nil {
password = string(enc)
}
}
stored.Hosts[i] = storedHost{
Name: h.Name,
HostName: h.HostName,
Port: h.Port,
User: h.User,
Password: password,
IdentityFile: h.IdentityFile,
}
}
data, err := json.Marshal(stored)
if err != nil {
return fmt.Errorf("marshal hosts: %w", err)
}
encrypted, err := encrypt(data, s.cipherKey)
if err != nil {
return fmt.Errorf("encrypt hosts: %w", err)
}
dir := filepath.Dir(s.configPath)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
return os.WriteFile(s.configPath, encrypted, 0o600)
}
// AddHost adds a custom host and saves.
func (s *HostStore) AddHost(host SSHHost) error {
host.FromSSHConfig = false
s.customHosts = append(s.customHosts, host)
return s.Save()
}
// RemoveHost removes a custom host by name.
func (s *HostStore) RemoveHost(name string) error {
for i, h := range s.customHosts {
if h.Name == name {
s.customHosts = append(s.customHosts[:i], s.customHosts[i+1:]...)
return s.Save()
}
}
return fmt.Errorf("host %q not found", name)
}
// AllHosts returns all hosts (from ssh config + custom).
func (s *HostStore) AllHosts() []SSHHost {
sshConfigHosts := ParseSSHConfig()
result := make([]SSHHost, 0, len(sshConfigHosts)+len(s.customHosts))
// Build a set of names from ssh config to avoid duplicates
seen := make(map[string]bool)
for _, h := range sshConfigHosts {
lower := strings.ToLower(h.Name)
seen[lower] = true
result = append(result, h)
}
for _, h := range s.customHosts {
lower := strings.ToLower(h.Name)
if !seen[lower] {
result = append(result, h)
seen[lower] = true
}
}
return result
}
// FindByName looks up a host by its Name field. Returns nil if not found.
func (s *HostStore) FindByName(name string) *SSHHost {
all := s.AllHosts()
for i := range all {
if strings.EqualFold(all[i].Name, name) {
return &all[i]
}
}
return nil
}
func encrypt(plaintext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
func decrypt(ciphertext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
return gcm.Open(nil, nonce, ciphertext, nil)
}

View file

@ -0,0 +1,275 @@
package vfs
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"golang.org/x/sys/unix"
)
type ListOptions struct {
ShowHidden bool
DirsFirst bool
SortBy string
SortReverse bool
}
func ListDir(path string, options ListOptions) ([]Entry, error) {
resolvedPath := path
if resolvedPath == "" {
resolvedPath = "."
}
dirEntries, err := os.ReadDir(resolvedPath)
if err != nil {
return nil, fmt.Errorf("read dir %s: %w", resolvedPath, err)
}
entries := make([]Entry, 0, len(dirEntries)+1)
if parent := filepath.Dir(resolvedPath); parent != resolvedPath {
entries = append(entries, Entry{
Name: "..",
Path: parent,
IsDir: true,
IsParent: true,
})
}
for _, dirEntry := range dirEntries {
name := dirEntry.Name()
hidden := strings.HasPrefix(name, ".")
if hidden && !options.ShowHidden {
continue
}
fullPath := filepath.Join(resolvedPath, name)
info, err := dirEntry.Info()
if err != nil {
continue
}
entry := Entry{
Name: name,
Path: fullPath,
Extension: ext(name),
Mode: info.Mode(),
Size: info.Size(),
ModifiedAt: info.ModTime(),
IsDir: info.IsDir(),
IsHidden: hidden,
}
if createdAt, ok := statBirthTime(fullPath); ok {
entry.CreatedAt = createdAt
entry.CreatedKnown = true
}
entries = append(entries, entry)
}
sort.SliceStable(entries, func(i, j int) bool {
left, right := entries[i], entries[j]
if left.IsParent != right.IsParent {
return left.IsParent
}
if options.DirsFirst && left.IsDir != right.IsDir {
return left.IsDir
}
comparison := compareEntries(left, right, options.SortBy)
if options.SortReverse {
return comparison > 0
}
return comparison < 0
})
return entries, nil
}
func compareEntries(left Entry, right Entry, sortBy string) int {
switch strings.ToLower(strings.TrimSpace(sortBy)) {
case "size":
if left.Size != right.Size {
return cmpInt64(left.Size, right.Size)
}
case "modified":
if !left.ModifiedAt.Equal(right.ModifiedAt) {
return cmpTimeDesc(left.ModifiedAt, right.ModifiedAt)
}
case "created":
if left.CreatedKnown != right.CreatedKnown {
if left.CreatedKnown {
return -1
}
return 1
}
if !left.CreatedAt.Equal(right.CreatedAt) {
return cmpTimeDesc(left.CreatedAt, right.CreatedAt)
}
case "extension":
if left.Extension != right.Extension {
return strings.Compare(left.Extension, right.Extension)
}
}
return strings.Compare(strings.ToLower(left.Name), strings.ToLower(right.Name))
}
func cmpInt64(left int64, right int64) int {
switch {
case left < right:
return -1
case left > right:
return 1
default:
return 0
}
}
func cmpTimeDesc(left time.Time, right time.Time) int {
switch {
case left.Equal(right):
return 0
case left.After(right):
return -1
default:
return 1
}
}
func statBirthTime(path string) (time.Time, bool) {
var stx unix.Statx_t
if err := unix.Statx(unix.AT_FDCWD, path, unix.AT_STATX_SYNC_AS_STAT, unix.STATX_BTIME, &stx); err == nil {
if stx.Mask&unix.STATX_BTIME != 0 {
return time.Unix(int64(stx.Btime.Sec), int64(stx.Btime.Nsec)), true
}
}
info, err := os.Lstat(path)
if err != nil {
return time.Time{}, false
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return time.Time{}, false
}
seconds := int64(stat.Ctim.Sec)
nanos := int64(stat.Ctim.Nsec)
if seconds == 0 && nanos == 0 {
return time.Time{}, false
}
return time.Unix(seconds, nanos), true
}
func DirectorySize(path string) (int64, error) {
var total int64
err := filepath.WalkDir(path, func(current string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
total += info.Size()
return nil
})
if err != nil {
return 0, err
}
return total, nil
}
func FindSelected(entries []Entry, key string) int {
for idx, entry := range entries {
if entry.MatchKey() == key {
return idx
}
}
return 0
}
func HumanSize(size int64) string {
if size < 0 {
return "?"
}
if size < 1024 {
return fmt.Sprintf("%d B", size)
}
units := []string{"KB", "MB", "GB", "TB"}
value := float64(size)
for _, unit := range units {
value /= 1024
if value < 1024 {
return fmt.Sprintf("%.1f %s", value, unit)
}
}
return fmt.Sprintf("%.1f PB", value/1024)
}
func ShortTime(t time.Time) string {
if t.IsZero() {
return "n/a"
}
return t.Format("2006-01-02 15:04")
}
func CompactTime(t time.Time) string {
if t.IsZero() {
return "n/a"
}
return t.Format("01-02 15:04")
}
func Permissions(mode fs.FileMode) string {
return mode.String()
}
func IsBinarySample(data []byte) bool {
if len(data) == 0 {
return false
}
var controls int
for _, b := range data {
if b == 0 {
return true
}
if b < 9 || (b > 13 && b < 32) {
controls++
}
}
return controls > len(data)/10
}
func SafeBase(path string) string {
base := filepath.Base(path)
if base == "." || base == string(filepath.Separator) {
return path
}
return base
}
func JoinPath(path string, name string) string {
return filepath.Join(path, name)
}
func ErrOverwrite(path string) error {
return fmt.Errorf("target already exists: %s", path)
}
func IsNotExist(err error) bool {
return errors.Is(err, os.ErrNotExist)
}

View file

@ -0,0 +1,836 @@
package theme
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
)
type Palette struct {
Name string
Background lipgloss.Color
Panel lipgloss.Color
PanelInactive lipgloss.Color
PanelElevated lipgloss.Color
StatusBar lipgloss.Color
Footer lipgloss.Color
Border lipgloss.Color
BorderActive lipgloss.Color
Text lipgloss.Color
Muted lipgloss.Color
Accent lipgloss.Color
Info lipgloss.Color
Success lipgloss.Color
Selection lipgloss.Color
Hover lipgloss.Color
Marked lipgloss.Color
Warning lipgloss.Color
Danger lipgloss.Color
ActivePath lipgloss.Color
ConfirmButton lipgloss.Color
CancelButton lipgloss.Color
ProgressFill lipgloss.Color
ProgressEmpty lipgloss.Color
HelpNav lipgloss.Color
HelpPanels lipgloss.Color
HelpDialogs lipgloss.Color
HelpMouse lipgloss.Color
Folder lipgloss.Color
TextFile lipgloss.Color
ConfigFile lipgloss.Color
ExecFile lipgloss.Color
ImageFile lipgloss.Color
BinaryFile lipgloss.Color
FooterKey lipgloss.Color
}
var builtInThemes = []string{
"catppuccin-mocha",
"catppuccin-macchiato",
"catppuccin-lavender",
"tokyo-night",
"gruvbox-dark",
"nord",
"one-dark",
"everforest",
"github-dark",
"ayu-dark",
"breeze",
"cyberpunk",
"dracula",
"eldritch",
"kanagawa",
"kanagawa-paper",
"rose-pine",
"solarized-dark",
"vesper",
}
func Names() []string {
values := make([]string, len(builtInThemes))
copy(values, builtInThemes)
return values
}
func Next(current string) string {
values := Names()
if len(values) == 0 {
return current
}
current = strings.ToLower(strings.TrimSpace(current))
for idx, value := range values {
if value == current {
return values[(idx+1)%len(values)]
}
}
return values[0]
}
func Resolve(name string) (Palette, error) {
switch strings.ToLower(name) {
case "catppuccin-mocha":
return Palette{
Name: "catppuccin-mocha",
Background: lipgloss.Color("#11111B"),
Panel: lipgloss.Color("#181825"),
PanelInactive: lipgloss.Color("#1E1E2E"),
PanelElevated: lipgloss.Color("#24273A"),
StatusBar: lipgloss.Color("#1E1E2E"),
Footer: lipgloss.Color("#11111B"),
Border: lipgloss.Color("#45475A"),
BorderActive: lipgloss.Color("#89B4FA"),
Text: lipgloss.Color("#CDD6F4"),
Muted: lipgloss.Color("#A6ADC8"),
Accent: lipgloss.Color("#F5C2E7"),
Info: lipgloss.Color("#89DCEB"),
Success: lipgloss.Color("#A6E3A1"),
Selection: lipgloss.Color("#313244"),
Hover: lipgloss.Color("#2A2B3C"),
Marked: lipgloss.Color("#F38BA8"),
Warning: lipgloss.Color("#F9E2AF"),
Danger: lipgloss.Color("#F38BA8"),
ActivePath: lipgloss.Color("#89DCEB"),
ConfirmButton: lipgloss.Color("#A6E3A1"),
CancelButton: lipgloss.Color("#F38BA8"),
ProgressFill: lipgloss.Color("#89B4FA"),
ProgressEmpty: lipgloss.Color("#45475A"),
HelpNav: lipgloss.Color("#89B4FA"),
HelpPanels: lipgloss.Color("#F9E2AF"),
HelpDialogs: lipgloss.Color("#CBA6F7"),
HelpMouse: lipgloss.Color("#F38BA8"),
Folder: lipgloss.Color("#89B4FA"),
TextFile: lipgloss.Color("#A6E3A1"),
ConfigFile: lipgloss.Color("#F9E2AF"),
ExecFile: lipgloss.Color("#FAB387"),
ImageFile: lipgloss.Color("#94E2D5"),
BinaryFile: lipgloss.Color("#CBA6F7"),
FooterKey: lipgloss.Color("#89DCEB"),
}, nil
case "catppuccin-lavender":
return Palette{
Name: "catppuccin-lavender",
Background: lipgloss.Color("#11111B"),
Panel: lipgloss.Color("#181825"),
PanelInactive: lipgloss.Color("#1E1E2E"),
PanelElevated: lipgloss.Color("#24273A"),
StatusBar: lipgloss.Color("#1E1E2E"),
Footer: lipgloss.Color("#11111B"),
Border: lipgloss.Color("#45475A"),
BorderActive: lipgloss.Color("#B4BEFE"),
Text: lipgloss.Color("#CDD6F4"),
Muted: lipgloss.Color("#A6ADC8"),
Accent: lipgloss.Color("#B4BEFE"),
Info: lipgloss.Color("#89DCEB"),
Success: lipgloss.Color("#A6E3A1"),
Selection: lipgloss.Color("#313244"),
Hover: lipgloss.Color("#2A2B3C"),
Marked: lipgloss.Color("#F38BA8"),
Warning: lipgloss.Color("#F9E2AF"),
Danger: lipgloss.Color("#F38BA8"),
ActivePath: lipgloss.Color("#B4BEFE"),
ConfirmButton: lipgloss.Color("#A6E3A1"),
CancelButton: lipgloss.Color("#F38BA8"),
ProgressFill: lipgloss.Color("#B4BEFE"),
ProgressEmpty: lipgloss.Color("#45475A"),
HelpNav: lipgloss.Color("#B4BEFE"),
HelpPanels: lipgloss.Color("#F9E2AF"),
HelpDialogs: lipgloss.Color("#CBA6F7"),
HelpMouse: lipgloss.Color("#F38BA8"),
Folder: lipgloss.Color("#B4BEFE"),
TextFile: lipgloss.Color("#A6E3A1"),
ConfigFile: lipgloss.Color("#F9E2AF"),
ExecFile: lipgloss.Color("#FAB387"),
ImageFile: lipgloss.Color("#89DCEB"),
BinaryFile: lipgloss.Color("#CBA6F7"),
FooterKey: lipgloss.Color("#B4BEFE"),
}, nil
case "tokyo-night":
return Palette{
Name: "tokyo-night",
Background: lipgloss.Color("#16161E"),
Panel: lipgloss.Color("#1A1B26"),
PanelInactive: lipgloss.Color("#24283B"),
PanelElevated: lipgloss.Color("#2A2F44"),
StatusBar: lipgloss.Color("#24283B"),
Footer: lipgloss.Color("#16161E"),
Border: lipgloss.Color("#3B4261"),
BorderActive: lipgloss.Color("#7AA2F7"),
Text: lipgloss.Color("#C0CAF5"),
Muted: lipgloss.Color("#9AA5CE"),
Accent: lipgloss.Color("#BB9AF7"),
Info: lipgloss.Color("#73DACA"),
Success: lipgloss.Color("#9ECE6A"),
Selection: lipgloss.Color("#292E42"),
Hover: lipgloss.Color("#252A3D"),
Marked: lipgloss.Color("#F7768E"),
Warning: lipgloss.Color("#E0AF68"),
Danger: lipgloss.Color("#F7768E"),
ActivePath: lipgloss.Color("#73DACA"),
ConfirmButton: lipgloss.Color("#9ECE6A"),
CancelButton: lipgloss.Color("#F7768E"),
ProgressFill: lipgloss.Color("#7AA2F7"),
ProgressEmpty: lipgloss.Color("#3B4261"),
HelpNav: lipgloss.Color("#7AA2F7"),
HelpPanels: lipgloss.Color("#E0AF68"),
HelpDialogs: lipgloss.Color("#BB9AF7"),
HelpMouse: lipgloss.Color("#F7768E"),
Folder: lipgloss.Color("#7AA2F7"),
TextFile: lipgloss.Color("#9ECE6A"),
ConfigFile: lipgloss.Color("#E0AF68"),
ExecFile: lipgloss.Color("#FF9E64"),
ImageFile: lipgloss.Color("#73DACA"),
BinaryFile: lipgloss.Color("#BB9AF7"),
FooterKey: lipgloss.Color("#73DACA"),
}, nil
case "gruvbox-dark":
return Palette{
Name: name,
Background: lipgloss.Color("#1D2021"),
Panel: lipgloss.Color("#282828"),
PanelInactive: lipgloss.Color("#32302F"),
PanelElevated: lipgloss.Color("#3C3836"),
StatusBar: lipgloss.Color("#32302F"),
Footer: lipgloss.Color("#1D2021"),
Border: lipgloss.Color("#504945"),
BorderActive: lipgloss.Color("#FABD2F"),
Text: lipgloss.Color("#EBDBB2"),
Muted: lipgloss.Color("#BDAE93"),
Accent: lipgloss.Color("#83A598"),
Info: lipgloss.Color("#8EC07C"),
Success: lipgloss.Color("#B8BB26"),
Selection: lipgloss.Color("#3C3836"),
Hover: lipgloss.Color("#45403D"),
Marked: lipgloss.Color("#FB4934"),
Warning: lipgloss.Color("#FE8019"),
Danger: lipgloss.Color("#FB4934"),
ActivePath: lipgloss.Color("#8EC07C"),
ConfirmButton: lipgloss.Color("#B8BB26"),
CancelButton: lipgloss.Color("#FB4934"),
ProgressFill: lipgloss.Color("#FABD2F"),
ProgressEmpty: lipgloss.Color("#504945"),
HelpNav: lipgloss.Color("#83A598"),
HelpPanels: lipgloss.Color("#FABD2F"),
HelpDialogs: lipgloss.Color("#D3869B"),
HelpMouse: lipgloss.Color("#FB4934"),
Folder: lipgloss.Color("#83A598"),
TextFile: lipgloss.Color("#B8BB26"),
ConfigFile: lipgloss.Color("#FABD2F"),
ExecFile: lipgloss.Color("#FE8019"),
ImageFile: lipgloss.Color("#8EC07C"),
BinaryFile: lipgloss.Color("#D3869B"),
FooterKey: lipgloss.Color("#8EC07C"),
}, nil
case "nord":
return Palette{
Name: name,
Background: lipgloss.Color("#2E3440"),
Panel: lipgloss.Color("#3B4252"),
PanelInactive: lipgloss.Color("#434C5E"),
PanelElevated: lipgloss.Color("#4C566A"),
StatusBar: lipgloss.Color("#434C5E"),
Footer: lipgloss.Color("#2E3440"),
Border: lipgloss.Color("#4C566A"),
BorderActive: lipgloss.Color("#88C0D0"),
Text: lipgloss.Color("#ECEFF4"),
Muted: lipgloss.Color("#D8DEE9"),
Accent: lipgloss.Color("#81A1C1"),
Info: lipgloss.Color("#8FBCBB"),
Success: lipgloss.Color("#A3BE8C"),
Selection: lipgloss.Color("#434C5E"),
Hover: lipgloss.Color("#505A70"),
Marked: lipgloss.Color("#BF616A"),
Warning: lipgloss.Color("#EBCB8B"),
Danger: lipgloss.Color("#BF616A"),
ActivePath: lipgloss.Color("#8FBCBB"),
ConfirmButton: lipgloss.Color("#A3BE8C"),
CancelButton: lipgloss.Color("#BF616A"),
ProgressFill: lipgloss.Color("#88C0D0"),
ProgressEmpty: lipgloss.Color("#4C566A"),
HelpNav: lipgloss.Color("#81A1C1"),
HelpPanels: lipgloss.Color("#EBCB8B"),
HelpDialogs: lipgloss.Color("#B48EAD"),
HelpMouse: lipgloss.Color("#BF616A"),
Folder: lipgloss.Color("#81A1C1"),
TextFile: lipgloss.Color("#A3BE8C"),
ConfigFile: lipgloss.Color("#EBCB8B"),
ExecFile: lipgloss.Color("#D08770"),
ImageFile: lipgloss.Color("#8FBCBB"),
BinaryFile: lipgloss.Color("#B48EAD"),
FooterKey: lipgloss.Color("#8FBCBB"),
}, nil
case "one-dark":
return Palette{
Name: "one-dark",
Background: lipgloss.Color("#282C34"),
Panel: lipgloss.Color("#21252B"),
PanelInactive: lipgloss.Color("#1B1D23"),
PanelElevated: lipgloss.Color("#2C313A"),
StatusBar: lipgloss.Color("#21252B"),
Footer: lipgloss.Color("#282C34"),
Border: lipgloss.Color("#3B4048"),
BorderActive: lipgloss.Color("#61AFEF"),
Text: lipgloss.Color("#ABB2BF"),
Muted: lipgloss.Color("#5C6370"),
Accent: lipgloss.Color("#61AFEF"),
Info: lipgloss.Color("#56B6C2"),
Success: lipgloss.Color("#98C379"),
Selection: lipgloss.Color("#3E4451"),
Hover: lipgloss.Color("#333841"),
Marked: lipgloss.Color("#E06C75"),
Warning: lipgloss.Color("#E5C07B"),
Danger: lipgloss.Color("#E06C75"),
ActivePath: lipgloss.Color("#56B6C2"),
ConfirmButton: lipgloss.Color("#98C379"),
CancelButton: lipgloss.Color("#E06C75"),
ProgressFill: lipgloss.Color("#61AFEF"),
ProgressEmpty: lipgloss.Color("#3B4048"),
HelpNav: lipgloss.Color("#61AFEF"),
HelpPanels: lipgloss.Color("#E5C07B"),
HelpDialogs: lipgloss.Color("#C678DD"),
HelpMouse: lipgloss.Color("#E06C75"),
Folder: lipgloss.Color("#61AFEF"),
TextFile: lipgloss.Color("#98C379"),
ConfigFile: lipgloss.Color("#E5C07B"),
ExecFile: lipgloss.Color("#D19A66"),
ImageFile: lipgloss.Color("#56B6C2"),
BinaryFile: lipgloss.Color("#C678DD"),
FooterKey: lipgloss.Color("#56B6C2"),
}, nil
case "everforest":
return Palette{
Name: "everforest",
Background: lipgloss.Color("#2D353B"),
Panel: lipgloss.Color("#272E33"),
PanelInactive: lipgloss.Color("#232A2E"),
PanelElevated: lipgloss.Color("#333C43"),
StatusBar: lipgloss.Color("#232A2E"),
Footer: lipgloss.Color("#2D353B"),
Border: lipgloss.Color("#475258"),
BorderActive: lipgloss.Color("#A7C080"),
Text: lipgloss.Color("#D3C6AA"),
Muted: lipgloss.Color("#859289"),
Accent: lipgloss.Color("#A7C080"),
Info: lipgloss.Color("#83C092"),
Success: lipgloss.Color("#A7C080"),
Selection: lipgloss.Color("#3A454A"),
Hover: lipgloss.Color("#364147"),
Marked: lipgloss.Color("#E67E80"),
Warning: lipgloss.Color("#DBBC7F"),
Danger: lipgloss.Color("#E67E80"),
ActivePath: lipgloss.Color("#83C092"),
ConfirmButton: lipgloss.Color("#A7C080"),
CancelButton: lipgloss.Color("#E67E80"),
ProgressFill: lipgloss.Color("#A7C080"),
ProgressEmpty: lipgloss.Color("#475258"),
HelpNav: lipgloss.Color("#A7C080"),
HelpPanels: lipgloss.Color("#DBBC7F"),
HelpDialogs: lipgloss.Color("#D699B6"),
HelpMouse: lipgloss.Color("#E67E80"),
Folder: lipgloss.Color("#A7C080"),
TextFile: lipgloss.Color("#D3C6AA"),
ConfigFile: lipgloss.Color("#DBBC7F"),
ExecFile: lipgloss.Color("#E69875"),
ImageFile: lipgloss.Color("#83C092"),
BinaryFile: lipgloss.Color("#D699B6"),
FooterKey: lipgloss.Color("#83C092"),
}, nil
case "github-dark":
return Palette{
Name: "github-dark",
Background: lipgloss.Color("#0D1117"),
Panel: lipgloss.Color("#161B22"),
PanelInactive: lipgloss.Color("#1C2128"),
PanelElevated: lipgloss.Color("#21262D"),
StatusBar: lipgloss.Color("#1C2128"),
Footer: lipgloss.Color("#0D1117"),
Border: lipgloss.Color("#30363D"),
BorderActive: lipgloss.Color("#58A6FF"),
Text: lipgloss.Color("#E6EDF3"),
Muted: lipgloss.Color("#8B949E"),
Accent: lipgloss.Color("#58A6FF"),
Info: lipgloss.Color("#39D353"),
Success: lipgloss.Color("#3FB950"),
Selection: lipgloss.Color("#21262D"),
Hover: lipgloss.Color("#262C36"),
Marked: lipgloss.Color("#F85149"),
Warning: lipgloss.Color("#D29922"),
Danger: lipgloss.Color("#F85149"),
ActivePath: lipgloss.Color("#39D353"),
ConfirmButton: lipgloss.Color("#3FB950"),
CancelButton: lipgloss.Color("#F85149"),
ProgressFill: lipgloss.Color("#58A6FF"),
ProgressEmpty: lipgloss.Color("#30363D"),
HelpNav: lipgloss.Color("#58A6FF"),
HelpPanels: lipgloss.Color("#D29922"),
HelpDialogs: lipgloss.Color("#BC8CFF"),
HelpMouse: lipgloss.Color("#F85149"),
Folder: lipgloss.Color("#58A6FF"),
TextFile: lipgloss.Color("#7EE787"),
ConfigFile: lipgloss.Color("#D29922"),
ExecFile: lipgloss.Color("#F0883E"),
ImageFile: lipgloss.Color("#39D353"),
BinaryFile: lipgloss.Color("#BC8CFF"),
FooterKey: lipgloss.Color("#39D353"),
}, nil
case "catppuccin-macchiato":
return Palette{
Name: "catppuccin-macchiato",
Background: lipgloss.Color("#181926"),
Panel: lipgloss.Color("#1E2030"),
PanelInactive: lipgloss.Color("#24273A"),
PanelElevated: lipgloss.Color("#2A2E3F"),
StatusBar: lipgloss.Color("#24273A"),
Footer: lipgloss.Color("#181926"),
Border: lipgloss.Color("#363A4F"),
BorderActive: lipgloss.Color("#C6A0F6"),
Text: lipgloss.Color("#CAD3F5"),
Muted: lipgloss.Color("#A5ADCB"),
Accent: lipgloss.Color("#C6A0F6"),
Info: lipgloss.Color("#91D7E3"),
Success: lipgloss.Color("#A6DA95"),
Selection: lipgloss.Color("#363A4F"),
Hover: lipgloss.Color("#2E3248"),
Marked: lipgloss.Color("#ED8796"),
Warning: lipgloss.Color("#F5A97F"),
Danger: lipgloss.Color("#ED8796"),
ActivePath: lipgloss.Color("#91D7E3"),
ConfirmButton: lipgloss.Color("#A6DA95"),
CancelButton: lipgloss.Color("#ED8796"),
ProgressFill: lipgloss.Color("#C6A0F6"),
ProgressEmpty: lipgloss.Color("#363A4F"),
HelpNav: lipgloss.Color("#C6A0F6"),
HelpPanels: lipgloss.Color("#F5A97F"),
HelpDialogs: lipgloss.Color("#C6A0F6"),
HelpMouse: lipgloss.Color("#ED8796"),
Folder: lipgloss.Color("#C6A0F6"),
TextFile: lipgloss.Color("#A6DA95"),
ConfigFile: lipgloss.Color("#F5A97F"),
ExecFile: lipgloss.Color("#EE99A0"),
ImageFile: lipgloss.Color("#91D7E3"),
BinaryFile: lipgloss.Color("#C6A0F6"),
FooterKey: lipgloss.Color("#91D7E3"),
}, nil
case "ayu-dark":
return Palette{
Name: "ayu-dark",
Background: lipgloss.Color("#0A0E14"),
Panel: lipgloss.Color("#0D1017"),
PanelInactive: lipgloss.Color("#11151D"),
PanelElevated: lipgloss.Color("#151A23"),
StatusBar: lipgloss.Color("#11151D"),
Footer: lipgloss.Color("#0A0E14"),
Border: lipgloss.Color("#1F2430"),
BorderActive: lipgloss.Color("#FFCC66"),
Text: lipgloss.Color("#B3B1AD"),
Muted: lipgloss.Color("#565B66"),
Accent: lipgloss.Color("#FF8F40"),
Info: lipgloss.Color("#95E6CB"),
Success: lipgloss.Color("#7FD962"),
Selection: lipgloss.Color("#1F2430"),
Hover: lipgloss.Color("#191E27"),
Marked: lipgloss.Color("#F26D78"),
Warning: lipgloss.Color("#FFCC66"),
Danger: lipgloss.Color("#F26D78"),
ActivePath: lipgloss.Color("#95E6CB"),
ConfirmButton: lipgloss.Color("#7FD962"),
CancelButton: lipgloss.Color("#F26D78"),
ProgressFill: lipgloss.Color("#FFCC66"),
ProgressEmpty: lipgloss.Color("#1F2430"),
HelpNav: lipgloss.Color("#FF8F40"),
HelpPanels: lipgloss.Color("#FFCC66"),
HelpDialogs: lipgloss.Color("#D4A0FF"),
HelpMouse: lipgloss.Color("#F26D78"),
Folder: lipgloss.Color("#FF8F40"),
TextFile: lipgloss.Color("#B3B1AD"),
ConfigFile: lipgloss.Color("#FFCC66"),
ExecFile: lipgloss.Color("#F29668"),
ImageFile: lipgloss.Color("#95E6CB"),
BinaryFile: lipgloss.Color("#D4A0FF"),
FooterKey: lipgloss.Color("#95E6CB"),
}, nil
case "breeze":
return Palette{
Name: "breeze",
Background: lipgloss.Color("#232629"),
Panel: lipgloss.Color("#2A2D30"),
PanelInactive: lipgloss.Color("#313437"),
PanelElevated: lipgloss.Color("#383B3E"),
StatusBar: lipgloss.Color("#313437"),
Footer: lipgloss.Color("#232629"),
Border: lipgloss.Color("#494D51"),
BorderActive: lipgloss.Color("#3DAEE9"),
Text: lipgloss.Color("#EFF0F1"),
Muted: lipgloss.Color("#B0B5BA"),
Accent: lipgloss.Color("#3DAEE9"),
Info: lipgloss.Color("#27E6A6"),
Success: lipgloss.Color("#27AE60"),
Selection: lipgloss.Color("#313437"),
Hover: lipgloss.Color("#35383B"),
Marked: lipgloss.Color("#ED1515"),
Warning: lipgloss.Color("#F67400"),
Danger: lipgloss.Color("#ED1515"),
ActivePath: lipgloss.Color("#27E6A6"),
ConfirmButton: lipgloss.Color("#27AE60"),
CancelButton: lipgloss.Color("#ED1515"),
ProgressFill: lipgloss.Color("#3DAEE9"),
ProgressEmpty: lipgloss.Color("#494D51"),
HelpNav: lipgloss.Color("#3DAEE9"),
HelpPanels: lipgloss.Color("#F67400"),
HelpDialogs: lipgloss.Color("#9B59B6"),
HelpMouse: lipgloss.Color("#ED1515"),
Folder: lipgloss.Color("#3DAEE9"),
TextFile: lipgloss.Color("#27AE60"),
ConfigFile: lipgloss.Color("#F67400"),
ExecFile: lipgloss.Color("#E67E22"),
ImageFile: lipgloss.Color("#27E6A6"),
BinaryFile: lipgloss.Color("#9B59B6"),
FooterKey: lipgloss.Color("#27E6A6"),
}, nil
case "cyberpunk":
return Palette{
Name: "cyberpunk",
Background: lipgloss.Color("#000B1A"),
Panel: lipgloss.Color("#0A1628"),
PanelInactive: lipgloss.Color("#0F1D30"),
PanelElevated: lipgloss.Color("#142338"),
StatusBar: lipgloss.Color("#0F1D30"),
Footer: lipgloss.Color("#000B1A"),
Border: lipgloss.Color("#1E3A5F"),
BorderActive: lipgloss.Color("#00FFF0"),
Text: lipgloss.Color("#E0E0E0"),
Muted: lipgloss.Color("#808080"),
Accent: lipgloss.Color("#FF00FF"),
Info: lipgloss.Color("#00FFF0"),
Success: lipgloss.Color("#00FF41"),
Selection: lipgloss.Color("#142338"),
Hover: lipgloss.Color("#192C42"),
Marked: lipgloss.Color("#FF0055"),
Warning: lipgloss.Color("#FFB000"),
Danger: lipgloss.Color("#FF0055"),
ActivePath: lipgloss.Color("#00FFF0"),
ConfirmButton: lipgloss.Color("#00FF41"),
CancelButton: lipgloss.Color("#FF0055"),
ProgressFill: lipgloss.Color("#FF00FF"),
ProgressEmpty: lipgloss.Color("#1E3A5F"),
HelpNav: lipgloss.Color("#FF00FF"),
HelpPanels: lipgloss.Color("#FFB000"),
HelpDialogs: lipgloss.Color("#FF00FF"),
HelpMouse: lipgloss.Color("#FF0055"),
Folder: lipgloss.Color("#00FFF0"),
TextFile: lipgloss.Color("#00FF41"),
ConfigFile: lipgloss.Color("#FFB000"),
ExecFile: lipgloss.Color("#FF6600"),
ImageFile: lipgloss.Color("#00FFF0"),
BinaryFile: lipgloss.Color("#FF00FF"),
FooterKey: lipgloss.Color("#00FFF0"),
}, nil
case "dracula":
return Palette{
Name: "dracula",
Background: lipgloss.Color("#21222C"),
Panel: lipgloss.Color("#282A36"),
PanelInactive: lipgloss.Color("#2F3242"),
PanelElevated: lipgloss.Color("#363850"),
StatusBar: lipgloss.Color("#2F3242"),
Footer: lipgloss.Color("#21222C"),
Border: lipgloss.Color("#44475A"),
BorderActive: lipgloss.Color("#BD93F9"),
Text: lipgloss.Color("#F8F8F2"),
Muted: lipgloss.Color("#6272A4"),
Accent: lipgloss.Color("#FF79C6"),
Info: lipgloss.Color("#8BE9FD"),
Success: lipgloss.Color("#50FA7B"),
Selection: lipgloss.Color("#44475A"),
Hover: lipgloss.Color("#3A3D52"),
Marked: lipgloss.Color("#FF5555"),
Warning: lipgloss.Color("#F1FA8C"),
Danger: lipgloss.Color("#FF5555"),
ActivePath: lipgloss.Color("#8BE9FD"),
ConfirmButton: lipgloss.Color("#50FA7B"),
CancelButton: lipgloss.Color("#FF5555"),
ProgressFill: lipgloss.Color("#FF79C6"),
ProgressEmpty: lipgloss.Color("#44475A"),
HelpNav: lipgloss.Color("#BD93F9"),
HelpPanels: lipgloss.Color("#F1FA8C"),
HelpDialogs: lipgloss.Color("#FF79C6"),
HelpMouse: lipgloss.Color("#FF5555"),
Folder: lipgloss.Color("#BD93F9"),
TextFile: lipgloss.Color("#50FA7B"),
ConfigFile: lipgloss.Color("#F1FA8C"),
ExecFile: lipgloss.Color("#FFB86C"),
ImageFile: lipgloss.Color("#8BE9FD"),
BinaryFile: lipgloss.Color("#FF79C6"),
FooterKey: lipgloss.Color("#8BE9FD"),
}, nil
case "eldritch":
return Palette{
Name: "eldritch",
Background: lipgloss.Color("#0B0D15"),
Panel: lipgloss.Color("#10121A"),
PanelInactive: lipgloss.Color("#161822"),
PanelElevated: lipgloss.Color("#1C1F2B"),
StatusBar: lipgloss.Color("#161822"),
Footer: lipgloss.Color("#0B0D15"),
Border: lipgloss.Color("#262A3B"),
BorderActive: lipgloss.Color("#67B0E8"),
Text: lipgloss.Color("#D3D7E0"),
Muted: lipgloss.Color("#8B8FA6"),
Accent: lipgloss.Color("#C278E8"),
Info: lipgloss.Color("#67B0E8"),
Success: lipgloss.Color("#74C287"),
Selection: lipgloss.Color("#1C1F2B"),
Hover: lipgloss.Color("#222638"),
Marked: lipgloss.Color("#E06868"),
Warning: lipgloss.Color("#E0A868"),
Danger: lipgloss.Color("#E06868"),
ActivePath: lipgloss.Color("#67B0E8"),
ConfirmButton: lipgloss.Color("#74C287"),
CancelButton: lipgloss.Color("#E06868"),
ProgressFill: lipgloss.Color("#C278E8"),
ProgressEmpty: lipgloss.Color("#262A3B"),
HelpNav: lipgloss.Color("#C278E8"),
HelpPanels: lipgloss.Color("#E0A868"),
HelpDialogs: lipgloss.Color("#C278E8"),
HelpMouse: lipgloss.Color("#E06868"),
Folder: lipgloss.Color("#67B0E8"),
TextFile: lipgloss.Color("#74C287"),
ConfigFile: lipgloss.Color("#E0A868"),
ExecFile: lipgloss.Color("#E08868"),
ImageFile: lipgloss.Color("#67B0E8"),
BinaryFile: lipgloss.Color("#C278E8"),
FooterKey: lipgloss.Color("#67B0E8"),
}, nil
case "kanagawa":
return Palette{
Name: "kanagawa",
Background: lipgloss.Color("#1F1F28"),
Panel: lipgloss.Color("#252535"),
PanelInactive: lipgloss.Color("#2A2A3C"),
PanelElevated: lipgloss.Color("#363646"),
StatusBar: lipgloss.Color("#2A2A3C"),
Footer: lipgloss.Color("#1F1F28"),
Border: lipgloss.Color("#54546D"),
BorderActive: lipgloss.Color("#7FB4CA"),
Text: lipgloss.Color("#DCD7BA"),
Muted: lipgloss.Color("#938AA9"),
Accent: lipgloss.Color("#DCA561"),
Info: lipgloss.Color("#7FB4CA"),
Success: lipgloss.Color("#76946A"),
Selection: lipgloss.Color("#363646"),
Hover: lipgloss.Color("#30304A"),
Marked: lipgloss.Color("#C34043"),
Warning: lipgloss.Color("#DCA561"),
Danger: lipgloss.Color("#C34043"),
ActivePath: lipgloss.Color("#7FB4CA"),
ConfirmButton: lipgloss.Color("#76946A"),
CancelButton: lipgloss.Color("#C34043"),
ProgressFill: lipgloss.Color("#DCA561"),
ProgressEmpty: lipgloss.Color("#54546D"),
HelpNav: lipgloss.Color("#DCA561"),
HelpPanels: lipgloss.Color("#DCA561"),
HelpDialogs: lipgloss.Color("#957FB8"),
HelpMouse: lipgloss.Color("#C34043"),
Folder: lipgloss.Color("#7FB4CA"),
TextFile: lipgloss.Color("#76946A"),
ConfigFile: lipgloss.Color("#DCA561"),
ExecFile: lipgloss.Color("#E6C384"),
ImageFile: lipgloss.Color("#7FB4CA"),
BinaryFile: lipgloss.Color("#957FB8"),
FooterKey: lipgloss.Color("#7FB4CA"),
}, nil
case "kanagawa-paper":
return Palette{
Name: "kanagawa-paper",
Background: lipgloss.Color("#1A1A22"),
Panel: lipgloss.Color("#222233"),
PanelInactive: lipgloss.Color("#2A2A3E"),
PanelElevated: lipgloss.Color("#323248"),
StatusBar: lipgloss.Color("#2A2A3E"),
Footer: lipgloss.Color("#1A1A22"),
Border: lipgloss.Color("#4A4A5E"),
BorderActive: lipgloss.Color("#9EC1C9"),
Text: lipgloss.Color("#C8C2B0"),
Muted: lipgloss.Color("#8B849E"),
Accent: lipgloss.Color("#C0A36E"),
Info: lipgloss.Color("#9EC1C9"),
Success: lipgloss.Color("#8EAA7A"),
Selection: lipgloss.Color("#323248"),
Hover: lipgloss.Color("#2C2C42"),
Marked: lipgloss.Color("#B5534E"),
Warning: lipgloss.Color("#C0A36E"),
Danger: lipgloss.Color("#B5534E"),
ActivePath: lipgloss.Color("#9EC1C9"),
ConfirmButton: lipgloss.Color("#8EAA7A"),
CancelButton: lipgloss.Color("#B5534E"),
ProgressFill: lipgloss.Color("#C0A36E"),
ProgressEmpty: lipgloss.Color("#4A4A5E"),
HelpNav: lipgloss.Color("#C0A36E"),
HelpPanels: lipgloss.Color("#C0A36E"),
HelpDialogs: lipgloss.Color("#A58DB8"),
HelpMouse: lipgloss.Color("#B5534E"),
Folder: lipgloss.Color("#9EC1C9"),
TextFile: lipgloss.Color("#8EAA7A"),
ConfigFile: lipgloss.Color("#C0A36E"),
ExecFile: lipgloss.Color("#D4BE8A"),
ImageFile: lipgloss.Color("#9EC1C9"),
BinaryFile: lipgloss.Color("#A58DB8"),
FooterKey: lipgloss.Color("#9EC1C9"),
}, nil
case "rose-pine":
return Palette{
Name: "rose-pine",
Background: lipgloss.Color("#191724"),
Panel: lipgloss.Color("#1F1D2E"),
PanelInactive: lipgloss.Color("#26233A"),
PanelElevated: lipgloss.Color("#2A273F"),
StatusBar: lipgloss.Color("#26233A"),
Footer: lipgloss.Color("#191724"),
Border: lipgloss.Color("#3B355A"),
BorderActive: lipgloss.Color("#C4A7E7"),
Text: lipgloss.Color("#E0DEF4"),
Muted: lipgloss.Color("#908CAA"),
Accent: lipgloss.Color("#EB6F92"),
Info: lipgloss.Color("#9CCFD8"),
Success: lipgloss.Color("#3E8FB0"),
Selection: lipgloss.Color("#312F44"),
Hover: lipgloss.Color("#2A2740"),
Marked: lipgloss.Color("#EB6F92"),
Warning: lipgloss.Color("#F6C177"),
Danger: lipgloss.Color("#EB6F92"),
ActivePath: lipgloss.Color("#9CCFD8"),
ConfirmButton: lipgloss.Color("#3E8FB0"),
CancelButton: lipgloss.Color("#EB6F92"),
ProgressFill: lipgloss.Color("#C4A7E7"),
ProgressEmpty: lipgloss.Color("#3B355A"),
HelpNav: lipgloss.Color("#C4A7E7"),
HelpPanels: lipgloss.Color("#F6C177"),
HelpDialogs: lipgloss.Color("#C4A7E7"),
HelpMouse: lipgloss.Color("#EB6F92"),
Folder: lipgloss.Color("#C4A7E7"),
TextFile: lipgloss.Color("#3E8FB0"),
ConfigFile: lipgloss.Color("#F6C177"),
ExecFile: lipgloss.Color("#E0DEF4"),
ImageFile: lipgloss.Color("#9CCFD8"),
BinaryFile: lipgloss.Color("#C4A7E7"),
FooterKey: lipgloss.Color("#9CCFD8"),
}, nil
case "solarized-dark":
return Palette{
Name: "solarized-dark",
Background: lipgloss.Color("#002B36"),
Panel: lipgloss.Color("#073642"),
PanelInactive: lipgloss.Color("#0D4A56"),
PanelElevated: lipgloss.Color("#125A68"),
StatusBar: lipgloss.Color("#0D4A56"),
Footer: lipgloss.Color("#002B36"),
Border: lipgloss.Color("#586E75"),
BorderActive: lipgloss.Color("#268BD2"),
Text: lipgloss.Color("#93A1A1"),
Muted: lipgloss.Color("#657B83"),
Accent: lipgloss.Color("#D33682"),
Info: lipgloss.Color("#2AA198"),
Success: lipgloss.Color("#859900"),
Selection: lipgloss.Color("#073642"),
Hover: lipgloss.Color("#0B4A56"),
Marked: lipgloss.Color("#DC322F"),
Warning: lipgloss.Color("#B58900"),
Danger: lipgloss.Color("#DC322F"),
ActivePath: lipgloss.Color("#2AA198"),
ConfirmButton: lipgloss.Color("#859900"),
CancelButton: lipgloss.Color("#DC322F"),
ProgressFill: lipgloss.Color("#268BD2"),
ProgressEmpty: lipgloss.Color("#586E75"),
HelpNav: lipgloss.Color("#268BD2"),
HelpPanels: lipgloss.Color("#B58900"),
HelpDialogs: lipgloss.Color("#D33682"),
HelpMouse: lipgloss.Color("#DC322F"),
Folder: lipgloss.Color("#268BD2"),
TextFile: lipgloss.Color("#859900"),
ConfigFile: lipgloss.Color("#B58900"),
ExecFile: lipgloss.Color("#CB4B16"),
ImageFile: lipgloss.Color("#2AA198"),
BinaryFile: lipgloss.Color("#D33682"),
FooterKey: lipgloss.Color("#2AA198"),
}, nil
case "vesper":
return Palette{
Name: "vesper",
Background: lipgloss.Color("#101010"),
Panel: lipgloss.Color("#181820"),
PanelInactive: lipgloss.Color("#1E1E30"),
PanelElevated: lipgloss.Color("#252540"),
StatusBar: lipgloss.Color("#1E1E30"),
Footer: lipgloss.Color("#101010"),
Border: lipgloss.Color("#303050"),
BorderActive: lipgloss.Color("#A0A0FF"),
Text: lipgloss.Color("#E0E0F0"),
Muted: lipgloss.Color("#8888AA"),
Accent: lipgloss.Color("#C0C0FF"),
Info: lipgloss.Color("#8080FF"),
Success: lipgloss.Color("#80FF80"),
Selection: lipgloss.Color("#252540"),
Hover: lipgloss.Color("#2A2A48"),
Marked: lipgloss.Color("#FF6080"),
Warning: lipgloss.Color("#FFB040"),
Danger: lipgloss.Color("#FF6080"),
ActivePath: lipgloss.Color("#8080FF"),
ConfirmButton: lipgloss.Color("#80FF80"),
CancelButton: lipgloss.Color("#FF6080"),
ProgressFill: lipgloss.Color("#C0C0FF"),
ProgressEmpty: lipgloss.Color("#303050"),
HelpNav: lipgloss.Color("#A0A0FF"),
HelpPanels: lipgloss.Color("#FFB040"),
HelpDialogs: lipgloss.Color("#C0C0FF"),
HelpMouse: lipgloss.Color("#FF6080"),
Folder: lipgloss.Color("#8080FF"),
TextFile: lipgloss.Color("#80FF80"),
ConfigFile: lipgloss.Color("#FFB040"),
ExecFile: lipgloss.Color("#FF8040"),
ImageFile: lipgloss.Color("#8080FF"),
BinaryFile: lipgloss.Color("#C0C0FF"),
FooterKey: lipgloss.Color("#8080FF"),
}, nil
default:
return Palette{}, fmt.Errorf("unknown theme %q", name)
}
}

View file

@ -0,0 +1,37 @@
package ui
import (
"os/exec"
"runtime"
"strings"
)
func resolveIconMode(mode string) (bool, string) {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "ascii":
return false, "Icon mode: ASCII"
case "nerd":
return true, ""
case "", "auto":
default:
return true, ""
}
if runtime.GOOS != "linux" {
return true, ""
}
if _, err := exec.LookPath("fc-list"); err != nil {
return true, ""
}
out, err := exec.Command("fc-list", ":", "family").Output()
if err != nil {
return true, ""
}
text := strings.ToLower(string(out))
if strings.Contains(text, "nerd font") {
return true, ""
}
return false, "Nerd Font not found: using ASCII icons"
}

View file

@ -0,0 +1,416 @@
package ui
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/png"
"io"
"os"
"os/exec"
"slices"
"strings"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
)
type overlayRect struct {
x int
y int
width int
height int
}
type imageOverlayManager struct {
cmd *exec.Cmd
stdin io.WriteCloser
running bool
identifier string
visible bool
backend string
backends []string
lastPath string
lastRect overlayRect
kittyTried bool
kittyOK bool
}
const kittyImageID = 31337
const assumedCellAspect = 0.5
const kittyPixelsPerCell = 16
func minFloat(a float64, b float64) float64 {
if a < b {
return a
}
return b
}
func newImageOverlayManager() *imageOverlayManager {
return &imageOverlayManager{identifier: "vcom-preview"}
}
func (m *imageOverlayManager) isKittyTerminal() bool {
term := strings.ToLower(os.Getenv("TERM"))
termProgram := strings.ToLower(os.Getenv("TERM_PROGRAM"))
return os.Getenv("KITTY_WINDOW_ID") != "" || strings.Contains(term, "kitty") || strings.Contains(termProgram, "kitty")
}
func (m *imageOverlayManager) canUseKitty() bool {
if !m.isKittyTerminal() {
return false
}
if m.kittyTried {
return m.kittyOK
}
m.kittyTried = true
m.kittyOK = true
return m.kittyOK
}
func writeKittyEscape(control string, payload []byte) error {
if payload == nil {
_, err := fmt.Fprintf(os.Stdout, "\x1b_G%s\x1b\\", control)
return err
}
encoded := base64.StdEncoding.EncodeToString(payload)
_, err := fmt.Fprintf(os.Stdout, "\x1b_G%s;%s\x1b\\", control, encoded)
return err
}
func resizeNearest(src image.Image, target image.Point) image.Image {
bounds := src.Bounds()
srcSize := bounds.Size()
if target.X <= 0 || target.Y <= 0 || srcSize.X <= target.X && srcSize.Y <= target.Y {
return src
}
dst := image.NewRGBA(image.Rect(0, 0, target.X, target.Y))
for y := 0; y < target.Y; y++ {
srcY := bounds.Min.Y + (y * srcSize.Y / target.Y)
for x := 0; x < target.X; x++ {
srcX := bounds.Min.X + (x * srcSize.X / target.X)
dst.Set(x, y, src.At(srcX, srcY))
}
}
return dst
}
func scaleImageToRect(img image.Image, rect overlayRect) image.Image {
size := img.Bounds().Size()
if size.X <= 0 || size.Y <= 0 || rect.width <= 0 || rect.height <= 0 {
return img
}
maxWidth := rect.width * kittyPixelsPerCell
maxHeight := rect.height * kittyPixelsPerCell
if maxWidth <= 0 || maxHeight <= 0 {
return img
}
scale := minFloat(float64(maxWidth)/float64(size.X), float64(maxHeight)/float64(size.Y))
if scale >= 1 {
return img
}
target := image.Point{
X: max(int(float64(size.X)*scale), 1),
Y: max(int(float64(size.Y)*scale), 1),
}
return resizeNearest(img, target)
}
func loadKittyPayload(path string, rect overlayRect) ([]byte, image.Point, error) {
file, err := os.Open(path)
if err != nil {
return nil, image.Point{}, err
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
return nil, image.Point{}, err
}
img = scaleImageToRect(img, rect)
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, image.Point{}, err
}
return buf.Bytes(), img.Bounds().Size(), nil
}
func kittyPlacementControl(rect overlayRect, size image.Point) string {
if size.X <= 0 || size.Y <= 0 {
return fmt.Sprintf("a=T,f=100,t=d,i=%d,c=%d,C=1", kittyImageID, rect.width)
}
availableAspect := (float64(rect.width) * assumedCellAspect) / float64(rect.height)
imageAspect := float64(size.X) / float64(size.Y)
if imageAspect >= availableAspect {
return fmt.Sprintf("a=T,f=100,t=d,i=%d,c=%d,C=1", kittyImageID, rect.width)
}
return fmt.Sprintf("a=T,f=100,t=d,i=%d,r=%d,C=1", kittyImageID, rect.height)
}
func (m *imageOverlayManager) showWithKitty(path string, rect overlayRect) error {
data, size, err := loadKittyPayload(path, rect)
if err != nil {
return err
}
if err := writeKittyEscape(fmt.Sprintf("a=d,d=I,i=%d", kittyImageID), nil); err != nil {
return err
}
if _, err := fmt.Fprintf(os.Stdout, "\x1b7\x1b[%d;%dH", rect.y+1, rect.x+1); err != nil {
return err
}
const chunkSize = 4096
for offset := 0; offset < len(data); offset += chunkSize {
end := min(offset+chunkSize, len(data))
chunk := data[offset:end]
more := 0
if end < len(data) {
more = 1
}
control := fmt.Sprintf("m=%d", more)
if offset == 0 {
control = fmt.Sprintf("%s,m=%d", kittyPlacementControl(rect, size), more)
}
if err := writeKittyEscape(control, chunk); err != nil {
return err
}
}
_, err = fmt.Fprint(os.Stdout, "\x1b8")
return err
}
func (m *imageOverlayManager) clearKitty() {
_ = writeKittyEscape(fmt.Sprintf("a=d,d=I,i=%d", kittyImageID), nil)
}
func (m *imageOverlayManager) backendOutput() string {
term := strings.ToLower(os.Getenv("TERM"))
order := make([]string, 0, 5)
switch {
case strings.Contains(term, "kitty"):
order = append(order, "kitty")
case os.Getenv("WAYLAND_DISPLAY") != "":
order = append(order, "wayland")
case os.Getenv("DISPLAY") != "":
order = append(order, "x11")
}
order = append(order, "wayland", "x11", "sixel", "kitty")
unique := make([]string, 0, len(order))
for _, backend := range order {
if !slices.Contains(unique, backend) {
unique = append(unique, backend)
}
}
return strings.Join(unique, ",")
}
func (m *imageOverlayManager) backendList() []string {
if len(m.backends) != 0 {
return m.backends
}
m.backends = strings.Split(m.backendOutput(), ",")
return m.backends
}
func (m *imageOverlayManager) startBackend(backend string) error {
cmd := exec.Command("ueberzugpp", "layer", "-o", backend)
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
if err := cmd.Start(); err != nil {
_ = stdin.Close()
return err
}
m.cmd = cmd
m.stdin = stdin
m.running = true
m.backend = backend
return nil
}
func (m *imageOverlayManager) startLegacyBackend() error {
cmd := exec.Command("ueberzug", "layer", "--parser", "json")
stdin, err := cmd.StdinPipe()
if err != nil {
return err
}
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
if err := cmd.Start(); err != nil {
_ = stdin.Close()
return err
}
m.cmd = cmd
m.stdin = stdin
m.running = true
m.backend = "ueberzug"
return nil
}
func (m *imageOverlayManager) ensureStarted() error {
if m.running {
return nil
}
if _, err := exec.LookPath("ueberzugpp"); err == nil {
var lastErr error
for _, backend := range m.backendList() {
if err := m.startBackend(backend); err != nil {
lastErr = err
continue
}
// Probe command channel right away; some backends terminate instantly.
if err := m.send(map[string]any{
"action": "remove",
"identifier": m.identifier,
}); err != nil {
lastErr = err
m.stop()
continue
}
return nil
}
if lastErr != nil {
return lastErr
}
}
if _, err := exec.LookPath("ueberzug"); err == nil {
if err := m.startLegacyBackend(); err != nil {
return err
}
if err := m.send(map[string]any{
"action": "remove",
"identifier": m.identifier,
}); err != nil {
m.stop()
return err
}
return nil
}
return fmt.Errorf("could not start image overlay backend")
}
func (m *imageOverlayManager) send(payload map[string]any) error {
if !m.running || m.stdin == nil {
return fmt.Errorf("overlay not running")
}
data, err := json.Marshal(payload)
if err != nil {
return err
}
_, err = io.WriteString(m.stdin, string(data)+"\n")
return err
}
func (m *imageOverlayManager) show(path string, rect overlayRect) error {
if rect.width <= 1 || rect.height <= 1 {
return nil
}
if m.visible && m.lastPath == path && m.lastRect == rect {
return nil
}
if m.canUseKitty() {
if m.backend == "ueberzugpp" {
m.hide()
}
if err := m.showWithKitty(path, rect); err == nil {
m.backend = "kitty"
m.visible = true
m.lastPath = path
m.lastRect = rect
return nil
}
}
for i := 0; i < len(m.backendList()); i++ {
if err := m.ensureStarted(); err != nil {
return err
}
payload := map[string]any{
"action": "add",
"identifier": m.identifier,
"path": path,
"x": rect.x,
"y": rect.y,
}
if m.backend == "ueberzug" {
payload["width"] = rect.width
payload["height"] = rect.height
} else {
payload["max_width"] = rect.width
payload["max_height"] = rect.height
payload["scaler"] = "fit_contain"
}
if err := m.send(payload); err == nil {
m.visible = true
m.lastPath = path
m.lastRect = rect
return nil
}
m.stop()
if m.backend != "ueberzug" && len(m.backends) > 0 {
m.backends = append(m.backends[1:], m.backends[0])
} else {
break
}
}
return fmt.Errorf("could not render image overlay")
}
func (m *imageOverlayManager) hide() {
if !m.visible {
return
}
switch m.backend {
case "kitty":
m.clearKitty()
case "ueberzugpp", "ueberzug":
if m.running {
_ = m.send(map[string]any{
"action": "remove",
"identifier": m.identifier,
})
}
}
m.visible = false
m.lastPath = ""
m.lastRect = overlayRect{}
}
func (m *imageOverlayManager) stop() {
m.hide()
if m.stdin != nil {
_ = m.stdin.Close()
m.stdin = nil
}
if m.cmd != nil && m.cmd.Process != nil {
_ = m.cmd.Process.Kill()
_, _ = m.cmd.Process.Wait()
}
m.cmd = nil
m.running = false
m.backend = ""
}

View file

@ -0,0 +1,97 @@
package ui
import "github.com/charmbracelet/bubbles/key"
type KeyMap struct {
Help key.Binding
Visual key.Binding
Caret key.Binding
View key.Binding
Rename key.Binding
Info key.Binding
Archive key.Binding
SelectText key.Binding
ToggleHidden key.Binding
CycleTheme key.Binding
CycleSort key.Binding
SSH key.Binding
Mirror key.Binding
Up key.Binding
Down key.Binding
SelectUp key.Binding
SelectDown key.Binding
PageUp key.Binding
PageDown key.Binding
Open key.Binding
Back key.Binding
Switch key.Binding
Filter key.Binding
Refresh key.Binding
DirSize key.Binding
Copy key.Binding
Move key.Binding
Mkdir key.Binding
Delete key.Binding
Unpack key.Binding
Confirm key.Binding
Background key.Binding
ProgressCancel key.Binding
Cancel key.Binding
Quit key.Binding
HistoryBack key.Binding
HistoryForward key.Binding
}
func DefaultKeyMap() KeyMap {
return KeyMap{
Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")),
Rename: key.NewBinding(key.WithKeys("f2", "r"), key.WithHelp("F2/r", "rename")),
View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")),
Visual: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "visual")),
Caret: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "caret")),
Archive: key.NewBinding(key.WithKeys("f4", "a"), key.WithHelp("F4/a", "archive")),
Info: key.NewBinding(key.WithKeys("f9", "o"), key.WithHelp("F9/o", "info")),
SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")),
ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")),
CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select theme")),
CycleSort: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "sort")),
SSH: key.NewBinding(key.WithKeys("f12", "s"), key.WithHelp("F12/s", "ssh")),
Mirror: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "mirror pane")),
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
SelectUp: key.NewBinding(key.WithKeys("shift+up", "K"), key.WithHelp("S-↑/K", "select up")),
SelectDown: key.NewBinding(key.WithKeys("shift+down", "J"), key.WithHelp("S-↓/J", "select down")),
PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("PgUp", "page up")),
PageDown: key.NewBinding(),
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")),
Back: key.NewBinding(key.WithKeys("backspace", "left"), key.WithHelp("←", "parent")),
Switch: key.NewBinding(key.WithKeys("tab", "h", "l"), key.WithHelp("Tab/h/l", "switch pane")),
Refresh: key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("C-r", "refresh")),
DirSize: key.NewBinding(key.WithKeys(" "), key.WithHelp("Space", "dir size")),
Copy: key.NewBinding(key.WithKeys("f5", "c"), key.WithHelp("F5/c", "copy")),
Move: key.NewBinding(key.WithKeys("f6", "m"), key.WithHelp("F6/m", "move")),
Mkdir: key.NewBinding(key.WithKeys("f7", "n"), key.WithHelp("F7/n", "mkdir")),
Delete: key.NewBinding(key.WithKeys("f8", "delete", "x"), key.WithHelp("F8/x", "delete")),
Unpack: key.NewBinding(key.WithKeys("f11", "e"), key.WithHelp("F11/e", "unpack")),
Confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("Enter/y", "confirm")),
Background: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "background")),
ProgressCancel: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "cancel transfer")),
HistoryBack: key.NewBinding(key.WithKeys("alt+left"), key.WithHelp("A-←", "back")),
HistoryForward: key.NewBinding(key.WithKeys("alt+right"), key.WithHelp("A-→", "forward")),
Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("Esc", "cancel")),
Quit: key.NewBinding(key.WithKeys("f10", "q", "ctrl+c"), key.WithHelp("F10/q", "quit")),
}
}
func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Rename, k.View, k.Archive, k.Copy, k.Move, k.Mkdir, k.Delete, k.Info, k.Quit, k.Unpack, k.SSH}
}
func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Help, k.Up, k.Down, k.SelectUp, k.SelectDown, k.Open, k.Back},
{k.Rename, k.View, k.Caret, k.Archive, k.Copy, k.Move, k.Delete},
{k.Unpack, k.SelectText, k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit},
}
}

View file

@ -0,0 +1,52 @@
package ui
import tea "github.com/charmbracelet/bubbletea"
// cyrillicToLatin maps Russian ЙЦУКЕН characters to their QWERTY positional
// equivalents. This allows all single-letter commands to work regardless of
// whether the user has a Russian or Latin keyboard layout active.
var cyrillicToLatin = map[rune]rune{
// Lowercase — same physical key position
'й': 'q', 'ц': 'w', 'у': 'e', 'к': 'r', 'е': 't', 'н': 'y',
'г': 'u', 'ш': 'i', 'щ': 'o', 'з': 'p', 'х': '[', 'ъ': ']',
'ф': 'a', 'ы': 's', 'в': 'd', 'а': 'f', 'п': 'g', 'р': 'h',
'о': 'j', 'л': 'k', 'д': 'l', 'ж': ';', 'э': '\'',
'я': 'z', 'ч': 'x', 'с': 'c', 'м': 'v', 'и': 'b', 'т': 'n',
'ь': 'm', 'б': ',', 'ю': '.', 'ё': '`',
// Uppercase — same physical key position with Shift
'Й': 'Q', 'Ц': 'W', 'У': 'E', 'К': 'R', 'Е': 'T', 'Н': 'Y',
'Г': 'U', 'Ш': 'I', 'Щ': 'O', 'З': 'P', 'Х': '{', 'Ъ': '}',
'Ф': 'A', 'Ы': 'S', 'В': 'D', 'А': 'F', 'П': 'G', 'Р': 'H',
'О': 'J', 'Л': 'K', 'Д': 'L', 'Ж': ':', 'Э': '"',
'Я': 'Z', 'Ч': 'X', 'С': 'C', 'М': 'V', 'И': 'B', 'Т': 'N',
'Ь': 'M', 'Б': '<', 'Ю': '>', 'Ё': '~',
}
// translateKeyMsg translates Cyrillic characters in a KeyMsg to their Latin
// positional equivalents (ЙЦУКЕН → QWERTY). If no translation is needed, the
// original message is returned unchanged.
func translateKeyMsg(msg tea.KeyMsg) tea.KeyMsg {
if msg.Type != tea.KeyRunes || len(msg.Runes) == 0 {
return msg
}
translated := make([]rune, len(msg.Runes))
changed := false
for i, r := range msg.Runes {
if latin, ok := cyrillicToLatin[r]; ok {
translated[i] = latin
changed = true
} else {
translated[i] = r
}
}
if !changed {
return msg
}
return tea.KeyMsg{
Type: msg.Type,
Runes: translated,
Alt: msg.Alt,
Paste: msg.Paste,
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,850 @@
package ui
import (
"fmt"
"path/filepath"
"strings"
"github.com/charmbracelet/lipgloss"
"vcom/internal/config"
vfs "vcom/internal/fs"
"vcom/internal/fs/remote"
"vcom/internal/theme"
)
type PaneID string
const (
PaneLeft PaneID = "left"
PaneRight PaneID = "right"
)
type BrowserPane struct {
ID PaneID
Path string
Entries []vfs.Entry
Cursor int
Offset int
Marked map[string]struct{}
Archive []ArchiveMount
Remote []RemoteMount
dirHistory []string
dirFuture []string
// cursorMemory remembers the last selected entry display name per directory
// within a session. Keyed by directory path. Restored when re-entering a dir.
cursorMemory map[string]string
}
type ArchiveMount struct {
SourcePath string
ParentPath string
RootPath string
TempDir string
}
// RemoteMount represents an active SSH/SFTP remote filesystem connection.
type RemoteMount struct {
Host remote.SSHHost
RemotePath string
Client *remote.SSHClient
Connected bool
}
func (p *BrowserPane) Selected() (vfs.Entry, bool) {
if len(p.Entries) == 0 || p.Cursor < 0 || p.Cursor >= len(p.Entries) {
return vfs.Entry{}, false
}
return p.Entries[p.Cursor], true
}
func (p *BrowserPane) SetEntries(entries []vfs.Entry, preserveKey string) {
p.Entries = entries
p.PruneMarks()
if len(entries) == 0 {
p.Cursor = 0
p.Offset = 0
return
}
if preserveKey != "" {
oldCursor := p.Cursor
p.Cursor = vfs.FindSelected(entries, preserveKey)
if p.Cursor != oldCursor {
p.Offset = 0
}
}
if p.Cursor >= len(entries) {
p.Cursor = len(entries) - 1
}
if p.Cursor < 0 {
p.Cursor = 0
}
if p.Offset > p.Cursor {
p.Offset = p.Cursor
}
}
func (p *BrowserPane) Move(delta int, pageSize int) {
if len(p.Entries) == 0 {
p.Cursor = 0
return
}
p.Cursor += delta
if p.Cursor < 0 {
p.Cursor = 0
}
if p.Cursor >= len(p.Entries) {
p.Cursor = len(p.Entries) - 1
}
if p.Cursor < p.Offset {
p.Offset = p.Cursor
}
if pageSize > 0 && p.Cursor >= p.Offset+pageSize {
p.Offset = p.Cursor - pageSize + 1
}
if p.Offset < 0 {
p.Offset = 0
}
}
func (p *BrowserPane) EnsureMarked(path string) {
if strings.TrimSpace(path) == "" {
return
}
if p.Marked == nil {
p.Marked = map[string]struct{}{}
}
p.Marked[path] = struct{}{}
}
func (p *BrowserPane) ToggleMarked(path string) {
if strings.TrimSpace(path) == "" {
return
}
if p.Marked == nil {
p.Marked = map[string]struct{}{}
}
if _, ok := p.Marked[path]; ok {
delete(p.Marked, path)
if len(p.Marked) == 0 {
p.Marked = nil
}
return
}
p.Marked[path] = struct{}{}
}
func (p *BrowserPane) IsMarked(path string) bool {
if p.Marked == nil {
return false
}
_, ok := p.Marked[path]
return ok
}
func (p *BrowserPane) ClearMarks() {
p.Marked = nil
}
func (p *BrowserPane) PruneMarks() {
if len(p.Marked) == 0 {
return
}
valid := map[string]struct{}{}
for _, entry := range p.Entries {
if entry.IsParent {
continue
}
valid[entry.Path] = struct{}{}
}
for path := range p.Marked {
if _, ok := valid[path]; !ok {
delete(p.Marked, path)
}
}
if len(p.Marked) == 0 {
p.Marked = nil
}
}
func (p *BrowserPane) MarkedEntries() []vfs.Entry {
if len(p.Marked) == 0 {
return nil
}
result := make([]vfs.Entry, 0, len(p.Marked))
for _, entry := range p.Entries {
if entry.IsParent {
continue
}
if p.IsMarked(entry.Path) {
result = append(result, entry)
}
}
return result
}
func (p *BrowserPane) EnsureVisible(pageSize int) {
if pageSize <= 0 {
return
}
if p.Cursor < p.Offset {
p.Offset = p.Cursor
}
if p.Cursor >= p.Offset+pageSize {
p.Offset = p.Cursor - pageSize + 1
}
if p.Offset < 0 {
p.Offset = 0
}
}
func (p *BrowserPane) SaveCursor(dirPath string, entryName string) {
if dirPath == "" || entryName == "" {
return
}
if p.cursorMemory == nil {
p.cursorMemory = map[string]string{}
}
p.cursorMemory[dirPath] = entryName
}
// LoadCursor returns the saved entry name for a directory, or empty string.
func (p *BrowserPane) LoadCursor(dirPath string) string {
if p.cursorMemory == nil {
return ""
}
return p.cursorMemory[dirPath]
}
func (p *BrowserPane) InArchive() bool {
return len(p.Archive) > 0
}
// PushHistory saves the current path to the back-stack and clears the forward-stack.
func (p *BrowserPane) PushHistory(path string) {
p.dirHistory = append(p.dirHistory, path)
p.dirFuture = nil
}
// PopHistory returns the most recent path from the back-stack.
func (p *BrowserPane) PopHistory() (string, bool) {
if len(p.dirHistory) == 0 {
return "", false
}
path := p.dirHistory[len(p.dirHistory)-1]
p.dirHistory = p.dirHistory[:len(p.dirHistory)-1]
return path, true
}
// PushFuture saves the current path to the forward-stack.
func (p *BrowserPane) PushFuture(path string) {
p.dirFuture = append(p.dirFuture, path)
}
// PopFuture returns the most recent path from the forward-stack.
func (p *BrowserPane) PopFuture() (string, bool) {
if len(p.dirFuture) == 0 {
return "", false
}
path := p.dirFuture[len(p.dirFuture)-1]
p.dirFuture = p.dirFuture[:len(p.dirFuture)-1]
return path, true
}
// HasHistory returns true if there are entries in the back-stack.
func (p *BrowserPane) HasHistory() bool {
return len(p.dirHistory) > 0
}
// HasFuture returns true if there are entries in the forward-stack.
func (p *BrowserPane) HasFuture() bool {
return len(p.dirFuture) > 0
}
// HistoryDepth returns the number of entries in the back-stack.
func (p *BrowserPane) HistoryDepth() int {
return len(p.dirHistory)
}
// FutureDepth returns the number of entries in the forward-stack.
func (p *BrowserPane) FutureDepth() int {
return len(p.dirFuture)
}
func (p *BrowserPane) PushArchive(mount ArchiveMount) {
p.Archive = append(p.Archive, mount)
}
func (p *BrowserPane) PopArchive() (ArchiveMount, bool) {
if len(p.Archive) == 0 {
return ArchiveMount{}, false
}
last := p.Archive[len(p.Archive)-1]
p.Archive = p.Archive[:len(p.Archive)-1]
return last, true
}
func (p *BrowserPane) CurrentArchive() (ArchiveMount, bool) {
if len(p.Archive) == 0 {
return ArchiveMount{}, false
}
return p.Archive[len(p.Archive)-1], true
}
func (p *BrowserPane) ClearArchives() []ArchiveMount {
if len(p.Archive) == 0 {
return nil
}
out := make([]ArchiveMount, len(p.Archive))
copy(out, p.Archive)
p.Archive = nil
return out
}
func (p *BrowserPane) PushRemote(mount RemoteMount) {
p.Remote = append(p.Remote, mount)
}
func (p *BrowserPane) PopRemote() (RemoteMount, bool) {
if len(p.Remote) == 0 {
return RemoteMount{}, false
}
last := p.Remote[len(p.Remote)-1]
p.Remote = p.Remote[:len(p.Remote)-1]
return last, true
}
func (p *BrowserPane) CurrentRemote() (RemoteMount, bool) {
if len(p.Remote) == 0 {
return RemoteMount{}, false
}
return p.Remote[len(p.Remote)-1], true
}
func (p *BrowserPane) InRemote() bool {
return len(p.Remote) > 0
}
func (p *BrowserPane) ClearRemotes() []RemoteMount {
if len(p.Remote) == 0 {
return nil
}
out := make([]RemoteMount, len(p.Remote))
copy(out, p.Remote)
p.Remote = nil
return out
}
func (p *BrowserPane) DisplayPath() string {
if len(p.Remote) > 0 {
top := p.Remote[len(p.Remote)-1]
statusIcon := "󰖩"
if !top.Connected {
statusIcon = "󰤭"
}
if top.RemotePath == "/" || top.RemotePath == "" {
return fmt.Sprintf("%s %s:", statusIcon, top.Host.Name)
}
return fmt.Sprintf("%s %s:%s", statusIcon, top.Host.Name, top.RemotePath)
}
if len(p.Archive) == 0 {
return p.Path
}
top := p.Archive[len(p.Archive)-1]
rel, err := filepath.Rel(top.RootPath, p.Path)
if err != nil {
return p.Path
}
rel = filepath.ToSlash(rel)
if rel == "." {
rel = ""
}
if rel == "" {
return top.SourcePath + "::"
}
return top.SourcePath + "::/" + rel
}
func renderPane(
pane BrowserPane,
cfg config.Config,
palette theme.Palette,
width int,
height int,
active bool,
hoverIndex int,
useNerdIcons bool,
) string {
if width <= 0 || height <= 0 {
return ""
}
borderColor := palette.Border
headerBg := palette.PanelInactive
bodyBg := palette.Panel
if active {
borderColor = palette.BorderActive
headerBg = palette.Selection
}
innerWidth := max(width-2, 1)
innerHeight := max(height-2, 1)
box := lipgloss.NewStyle().
Width(innerWidth).
Height(innerHeight).
Background(bodyBg).
Foreground(palette.Text).
BorderStyle(borderStyle(cfg.UI.Border)).
BorderForeground(borderColor).
BorderBackground(bodyBg)
header := lipgloss.NewStyle().
Render(renderPaneHeader(pane, cfg, palette, innerWidth, active, headerBg))
isSSHHostList := pane.Path == "ssh://"
rowsHeight := max(innerHeight-2, 1)
headerRow := renderColumnsHeader(cfg, innerWidth, palette, bodyBg, useNerdIcons, isSSHHostList)
rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active, hoverIndex, bodyBg, useNerdIcons, isSSHHostList)
content := lipgloss.JoinVertical(lipgloss.Left, header, headerRow, rows)
return box.Render(content)
}
func renderPaneHeader(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, active bool, headerBg lipgloss.Color) string {
pathWidth := max(width, 4)
pathStyle := lipgloss.NewStyle().
Width(pathWidth).
Background(headerBg).
Foreground(palette.Text).
Bold(active)
if active {
pathStyle = pathStyle.Foreground(palette.ActivePath)
}
return lipgloss.NewStyle().
Width(width).
Background(headerBg).
Render(pathStyle.Render(truncateMiddle(compactPath(pane.DisplayPath(), cfg.UI.PathDisplay), pathWidth)))
}
func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette, background lipgloss.Color, useNerdIcons bool, hideExtraCols bool) string {
columns := buildColumns(cfg, width, useNerdIcons, hideExtraCols)
parts := make([]string, 0, len(columns))
for idx, column := range columns {
style := lipgloss.NewStyle().
Width(column.Width).
Foreground(palette.Muted).
Background(background).
Bold(true)
if column.AlignRight {
style = style.Align(lipgloss.Right)
}
parts = append(parts, style.Render(truncateRight(column.Title, column.Width)))
if idx < len(columns)-1 {
parts = append(parts, columnSeparator(column.Key, palette, background))
}
}
return lipgloss.NewStyle().
Width(width).
Background(background).
Render(strings.Join(parts, ""))
}
func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool, hoverIndex int, background lipgloss.Color, useNerdIcons bool, hideExtraCols bool) string {
if len(pane.Entries) == 0 {
return lipgloss.NewStyle().
Width(width).
Height(height).
Padding(1, 1).
Background(background).
Foreground(palette.Muted).
Render("Empty directory")
}
visibleHeight := max(height, 1)
pane.EnsureVisible(visibleHeight)
end := min(len(pane.Entries), pane.Offset+visibleHeight)
lines := make([]string, 0, visibleHeight)
for idx := pane.Offset; idx < end; idx++ {
entry := pane.Entries[idx]
isSelected := idx == pane.Cursor && active
marked := !entry.IsParent && pane.IsMarked(entry.Path)
row := renderEntryRow(entry, cfg, width, isSelected, marked, idx == hoverIndex, active, palette, background, useNerdIcons, hideExtraCols)
lines = append(lines, row)
}
for len(lines) < visibleHeight {
lines = append(lines, lipgloss.NewStyle().Width(width).Background(background).Render(""))
}
return lipgloss.NewStyle().
Width(width).
Background(background).
Render(strings.Join(lines, "\n"))
}
func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, marked bool, hovered bool, active bool, palette theme.Palette, baseBackground lipgloss.Color, useNerdIcons bool, hideExtraCols bool) string {
columns := buildColumns(cfg, width, useNerdIcons, hideExtraCols)
rowBackground := baseBackground
switch {
case marked:
rowBackground = palette.Marked
case selected:
rowBackground = palette.Selection
case hovered:
rowBackground = palette.Hover
}
parts := make([]string, 0, len(columns))
for idx, column := range columns {
value := column.Value(entry, cfg.Browser.HumanReadableSize)
foreground := entryColor(entry, palette)
if marked {
foreground = palette.Background
}
style := lipgloss.NewStyle().
Width(column.Width).
Foreground(foreground).
Background(rowBackground)
if entry.IsHidden && !marked {
style = style.Foreground(palette.Muted)
}
if column.AlignRight {
style = style.Align(lipgloss.Right)
}
parts = append(parts, style.Render(truncateForColumn(value, column.Width, column.AlignRight)))
if idx < len(columns)-1 {
parts = append(parts, columnSeparator(column.Key, palette, rowBackground))
}
}
rowStyle := lipgloss.NewStyle().Width(width).Background(rowBackground)
if selected && active {
rowStyle = rowStyle.Bold(true)
}
return rowStyle.Render(strings.Join(parts, ""))
}
type columnSpec struct {
Key string
Title string
Width int
MinWidth int
AlignRight bool
Value func(entry vfs.Entry, human bool) string
}
func buildColumns(cfg config.Config, totalWidth int, useNerdIcons bool, hideExtraCols bool) []columnSpec {
fixed := []columnSpec{}
if cfg.Browser.Columns.Permissions {
fixed = append(fixed, columnSpec{
Key: "permissions",
Title: "Perms",
Width: 10,
MinWidth: 9,
Value: func(entry vfs.Entry, _ bool) string {
return vfs.Permissions(entry.Mode)
},
})
}
if cfg.Browser.Columns.Extension {
fixed = append(fixed, columnSpec{
Key: "extension",
Title: "Ext",
Width: 6,
MinWidth: 4,
Value: func(entry vfs.Entry, _ bool) string {
if entry.IsDir {
return ""
}
return entry.Extension
},
})
}
if cfg.Browser.Columns.Size && !hideExtraCols {
fixed = append(fixed, columnSpec{
Key: "size",
Title: "Size",
Width: 9,
MinWidth: 6,
AlignRight: true,
Value: func(entry vfs.Entry, human bool) string {
if entry.IsDir {
if !entry.DirSizeKnown {
return ""
}
if human {
return vfs.HumanSize(entry.Size)
}
return fmt.Sprintf("%d", entry.Size)
}
if human {
return vfs.HumanSize(entry.Size)
}
return fmt.Sprintf("%d", entry.Size)
},
})
}
if cfg.Browser.Columns.Created {
fixed = append(fixed, columnSpec{
Key: "created",
Title: "Created",
Width: 11,
MinWidth: 8,
Value: func(entry vfs.Entry, _ bool) string {
if !entry.CreatedKnown {
return "n/a"
}
return vfs.CompactTime(entry.CreatedAt)
},
})
}
if cfg.Browser.Columns.Modified && !hideExtraCols {
fixed = append(fixed, columnSpec{
Key: "modified",
Title: "Modified",
Width: 11,
MinWidth: 8,
Value: func(entry vfs.Entry, _ bool) string {
if entry.IsParent {
return ""
}
return vfs.CompactTime(entry.ModifiedAt)
},
})
}
minNameWidth := 4
gaps := 0
for _, column := range fixed {
gaps += separatorWidth(column.Key)
}
availableForColumns := totalWidth - gaps
if availableForColumns < minNameWidth {
availableForColumns = minNameWidth
}
fixedWidth := 0
for _, column := range fixed {
fixedWidth += column.Width
}
for fixedWidth+minNameWidth > availableForColumns {
changed := false
for idx := len(fixed) - 1; idx >= 0 && fixedWidth+minNameWidth > availableForColumns; idx-- {
if fixed[idx].Width > fixed[idx].MinWidth {
fixed[idx].Width--
fixedWidth--
changed = true
}
}
if !changed {
break
}
}
nameWidth := max(availableForColumns-fixedWidth, minNameWidth)
name := columnSpec{
Key: "name",
Title: "Name",
Width: nameWidth,
MinWidth: minNameWidth,
Value: func(entry vfs.Entry, _ bool) string {
return entryIcon(entry, useNerdIcons) + " " + entry.DisplayName()
},
}
return append([]columnSpec{name}, fixed...)
}
func columnSeparator(columnKey string, palette theme.Palette, background lipgloss.Color) string {
width := separatorWidth(columnKey)
style := lipgloss.NewStyle().
Width(width).
Foreground(palette.Border)
if background != lipgloss.Color("") {
style = style.Background(background)
}
return style.Render(strings.Repeat(" ", width))
}
func separatorWidth(columnKey string) int {
if columnKey == "size" {
return 2
}
return 1
}
func borderStyle(value string) lipgloss.Border {
switch strings.ToLower(value) {
case "double":
return lipgloss.DoubleBorder()
case "thick":
return lipgloss.ThickBorder()
default:
return lipgloss.RoundedBorder()
}
}
func compactPath(path string, mode string) string {
switch strings.ToLower(mode) {
case "full":
return path
case "smart":
return smartPath(path, 42)
default:
return vfs.SafeBase(path)
}
}
func smartPath(path string, maxWidth int) string {
if lipgloss.Width(path) <= maxWidth {
return path
}
return truncateMiddle(path, maxWidth)
}
func truncateMiddle(value string, maxWidth int) string {
if maxWidth <= 0 || lipgloss.Width(value) <= maxWidth {
return value
}
if maxWidth <= 3 {
return trimToWidthRight(value, maxWidth)
}
left := maxWidth/2 - 1
right := maxWidth - left - 1
if left < 1 {
left = 1
}
if right < 1 {
right = 1
}
return trimToWidthRight(value, left) + "…" + trimToWidthLeft(value, right)
}
func truncateRight(value string, maxWidth int) string {
if maxWidth <= 0 || lipgloss.Width(value) <= maxWidth {
return value
}
if maxWidth == 1 {
return trimToWidthRight(value, 1)
}
return trimToWidthRight(value, maxWidth-1) + "…"
}
func truncateForColumn(value string, maxWidth int, alignRight bool) string {
if lipgloss.Width(value) <= maxWidth {
return value
}
if alignRight {
if maxWidth <= 1 {
return trimToWidthLeft(value, 1)
}
return "…" + trimToWidthLeft(value, maxWidth-1)
}
return truncateRight(value, maxWidth)
}
func trimToWidthRight(value string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
width := 0
var builder strings.Builder
for _, r := range value {
rw := lipgloss.Width(string(r))
if width+rw > maxWidth {
break
}
builder.WriteRune(r)
width += rw
}
return builder.String()
}
func trimToWidthLeft(value string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
runes := []rune(value)
width := 0
start := len(runes)
for i := len(runes) - 1; i >= 0; i-- {
rw := lipgloss.Width(string(runes[i]))
if width+rw > maxWidth {
break
}
width += rw
start = i
}
return string(runes[start:])
}
func entryIcon(entry vfs.Entry, useNerdIcons bool) string {
if !useNerdIcons {
switch entry.Category() {
case "parent":
return "<-"
case "remote":
return "[SV]"
case "directory":
return "[D]"
case "config":
return "[C]"
case "text":
return "[T]"
case "image":
return "[I]"
case "executable":
return "[X]"
case "archive":
return "[A]"
default:
return "[F]"
}
}
switch entry.Category() {
case "parent":
return "↩"
case "remote":
return "󰒋"
case "directory":
return ""
case "config":
return ""
case "text":
return "󰈙"
case "image":
return "󰋩"
case "executable":
return "󰆍"
case "archive":
return ""
default:
return "󰈔"
}
}
func entryColor(entry vfs.Entry, palette theme.Palette) lipgloss.Color {
switch entry.Category() {
case "remote":
return palette.ExecFile
case "directory", "parent":
return palette.Folder
case "config":
return palette.ConfigFile
case "text":
return palette.TextFile
case "image":
return palette.ImageFile
case "executable":
return palette.ExecFile
default:
return palette.BinaryFile
}
}

View file

@ -0,0 +1,244 @@
package ui
import (
"fmt"
"path"
"strings"
vfs "vcom/internal/fs"
"vcom/internal/fs/remote"
"github.com/charmbracelet/bubbles/textinput"
)
// isRemoteHostEntry returns true if the entry represents an SSH host.
func isRemoteHostEntry(entry vfs.Entry) bool {
return entry.IsRemote
}
// isRemoteAddHostEntry returns true if the entry is the "Add host" special item.
func isRemoteAddHostEntry(entry vfs.Entry) bool {
return entry.IsRemote && entry.Name == "+ Add host"
}
// buildSSHHostEntries creates virtual directory entries for SSH hosts.
// connectedHosts optionally specifies which hosts have active connections
// (host name -> true). When non-nil, entries show a connection status prefix.
func buildSSHHostEntries(store *remote.HostStore, connectedHosts map[string]bool) []vfs.Entry {
hosts := store.AllHosts()
entries := make([]vfs.Entry, 0, len(hosts)+1)
// Find the longest host name so we can pad them all to the same width,
// making the (user@host) suffix align vertically across entries.
maxNameLen := 0
for _, h := range hosts {
if l := len(h.Name); l > maxNameLen {
maxNameLen = l
}
}
for _, h := range hosts {
isConnected := connectedHosts != nil && connectedHosts[h.Name]
addr := h.HostName
if h.Port != "" && h.Port != "22" {
addr = fmt.Sprintf("%s:%s", addr, h.Port)
}
suffix := ""
if h.User != "" {
suffix = fmt.Sprintf(" (%s@%s)", h.User, addr)
} else {
suffix = fmt.Sprintf(" (%s)", addr)
}
paddedName := h.Name + strings.Repeat(" ", maxNameLen-len(h.Name))
entries = append(entries, vfs.Entry{
Name: paddedName + suffix,
Path: "ssh://" + h.Name,
IsDir: true,
IsRemote: true,
Connected: isConnected,
RemoteHostName: h.Name,
Mode: 0o755,
})
}
// Add "Add host" entry at the bottom
entries = append(entries, vfs.Entry{
Name: "+ Add host",
Path: "ssh://add-host",
IsDir: false,
IsRemote: true,
})
return entries
}
// SSHConnectDialogInputs creates the text input fields for the SSH connect dialog.
func SSHConnectDialogInputs() []textinput.Model {
fields := make([]textinput.Model, 5)
placeholders := []string{"my-server", "192.168.1.100", "22", "root", ""}
for i := range fields {
ti := textinput.New()
ti.Placeholder = placeholders[i]
ti.CharLimit = 128
ti.Width = 40
if i == 4 {
ti.EchoMode = textinput.EchoPassword
ti.EchoCharacter = '•'
}
if i == 0 {
ti.Focus()
}
fields[i] = ti
}
return fields
}
// sshDialogLabel returns the label for an SSH dialog field at the given index.
func sshDialogLabel(index int) string {
lbls := []string{"Name:", "Hostname/IP:", "Port:", "User:", "Password:"}
if index >= 0 && index < len(lbls) {
return lbls[index]
}
return ""
}
// sshDialogValue returns the value for an SSH dialog field.
func sshDialogValue(inputs []textinput.Model, index int) string {
if index >= 0 && index < len(inputs) {
return inputs[index].Value()
}
return ""
}
// buildSSHHostFromDialog creates an SSHHost from dialog input values.
func buildSSHHostFromDialog(inputs []textinput.Model) remote.SSHHost {
host := remote.SSHHost{
Name: strings.TrimSpace(sshDialogValue(inputs, 0)),
HostName: strings.TrimSpace(sshDialogValue(inputs, 1)),
Port: strings.TrimSpace(sshDialogValue(inputs, 2)),
User: strings.TrimSpace(sshDialogValue(inputs, 3)),
Password: sshDialogValue(inputs, 4),
}
if host.Port == "" {
host.Port = "22"
}
return host
}
// formatSSHConnectHelp returns the help text for the SSH connect dialog.
func formatSSHConnectHelp() string {
return `Fields:
Name alias for this host (required)
Hostname/IP server address (required)
Port SSH port, default 22
User login username (required)
Password password for password-based auth
Navigation: Tab / Shift+Tab between fields
Confirm: Enter
Close: Esc / q
Help: F1 / ?`
}
// remoteDirToEntries reads a remote directory via SFTP and converts to vfs.Entry slice.
func remoteDirToEntries(remotePath string, sshClient *remote.SSHClient) ([]vfs.Entry, error) {
fileInfos, err := sshClient.ReadDir(remotePath)
if err != nil {
return nil, err
}
entries := make([]vfs.Entry, 0, len(fileInfos)+1)
// Add parent directory entry if not at root
if remotePath != "/" {
parent := path.Dir(remotePath)
if parent == "" {
parent = "/"
}
entries = append(entries, vfs.Entry{
Name: "..",
Path: parent,
IsDir: true,
IsParent: true,
})
}
for _, info := range fileInfos {
name := info.Name()
isDir := info.IsDir()
fullPath := path.Join(remotePath, name)
ext := ""
if idx := strings.LastIndex(name, "."); idx > 0 {
ext = strings.ToLower(name[idx+1:])
}
entry := vfs.Entry{
Name: name,
Path: fullPath,
Extension: ext,
Mode: info.Mode(),
Size: info.Size(),
ModifiedAt: info.ModTime(),
IsDir: isDir,
IsHidden: strings.HasPrefix(name, "."),
}
entries = append(entries, entry)
}
return entries, nil
}
// sshState holds SSH-related state for the model.
type sshState struct {
store *remote.HostStore
inputs []textinput.Model
inputFocus int
showHelp bool
// Connection test state
testingConn bool // true while an async connection test is in progress
cancelTest func() // call to abort a pending connection test
// connectedHosts tracks which host names have active SSH connections.
// Updated when connections are established or closed.
connectedHosts map[string]bool
// activeClients stores SSH clients for hosts whose connections are kept alive
// after returning to the host list. Clients are closed on full SSH mode exit.
activeClients map[string]*remote.SSHClient
}
// cycleInput shifts focus between dialog inputs by delta (+1/-1).
func (s *sshState) cycleInput(delta int) {
if s == nil || len(s.inputs) == 0 {
return
}
s.inputs[s.inputFocus].Blur()
s.inputFocus = (s.inputFocus + delta) % len(s.inputs)
if s.inputFocus < 0 {
s.inputFocus += len(s.inputs)
}
s.inputs[s.inputFocus].Focus()
}
// newSSHState creates a new sshState with a HostStore.
func newSSHState() (*sshState, error) {
store, err := remote.NewHostStore()
if err != nil {
return nil, err
}
return &sshState{
store: store,
inputs: SSHConnectDialogInputs(),
inputFocus: 0,
activeClients: make(map[string]*remote.SSHClient),
showHelp: false,
connectedHosts: make(map[string]bool),
}, nil
}

View file

@ -0,0 +1,497 @@
# Extended Preview — PDF, Audio, Video via External Utilities
## Overview
Add rich preview support for three new file categories by leveraging external CLI tools. If a tool is not installed, the file falls back to the existing binary-file display.
```mermaid
flowchart TD
A[BuildPreview entry] --> B{Is directory?}
B -->|Yes| C[buildDirectoryPreview]
B -->|No| D[Open file, read header]
D --> E{Detect image?}
E -->|Yes| F[PreviewKindImage]
E -->|No| G{Detect PDF ext?}
G -->|Yes| H{pdftotext available?}
H -->|Yes| I[PreviewKindPDF - extract text]
H -->|No| J[Fallback to binary]
G -->|No| K{Detect audio ext?}
K -->|Yes| L{ffprobe available?}
L -->|Yes| M[PreviewKindAudio - show metadata]
L -->|No| N[Fallback to binary]
K -->|No| O{Detect video ext?}
O -->|Yes| P{ffprobe available?}
P -->|Yes| Q[PreviewKindVideo - show metadata]
P -->|No| R[Fallback to binary]
O -->|No| S[Is binary sample?]
S -->|Yes| T[PreviewKindBinary]
S -->|No| U[PreviewKindText + syntax highlight]
```
## 1. New PreviewKind Constants
File: [`internal/fs/preview.go`](internal/fs/preview.go) (around line 28-35)
Add three new constants to the `PreviewKind` type:
- `PreviewKindPDF PreviewKind = "pdf"`
- `PreviewKindAudio PreviewKind = "audio"`
- `PreviewKindVideo PreviewKind = "video"`
## 2. Extended Metadata Struct
File: [`internal/fs/preview.go`](internal/fs/preview.go) — `Metadata` struct (line 37-48)
Add new optional fields:
```go
type Metadata struct {
// ... existing fields ...
// Extended preview metadata
Duration string // audio/video duration (e.g. "3:42")
Bitrate string // audio/video bitrate (e.g. "320 kbps")
AudioCodec string // audio codec (e.g. "aac", "mp3")
VideoCodec string // video codec (e.g. "h264", "vp9")
SampleRate string // audio sample rate (e.g. "44100 Hz")
Channels string // audio channels (e.g. "stereo")
PageCount string // PDF page count
Dimensions string // video dimensions (e.g. "1920x1080")
}
```
## 3. New Extension Maps
File: [`internal/fs/entry.go`](internal/fs/entry.go) (around line 55-63)
Add three new extension sets:
```go
pdfExtensions = map[string]struct{}{
"pdf": {},
}
audioExtensions = map[string]struct{}{
"mp3": {}, "flac": {}, "ogg": {}, "opus": {}, "wav": {},
"aac": {}, "m4a": {}, "wma": {}, "dsf": {}, "ape": {},
}
videoExtensions = map[string]struct{}{
"mp4": {}, "mkv": {}, "mov": {}, "avi": {}, "webm": {},
"m4v": {}, "wmv": {}, "flv": {}, "ts": {}, "mts": {},
}
```
## 4. New Categories in `Category()` Method
File: [`internal/fs/entry.go`](internal/fs/entry.go) — `Category()` method (line 102-123)
Add three new cases in the switch statement (before `default`):
```go
case hasExt(pdfExtensions, e.Extension):
return "pdf"
case hasExt(audioExtensions, e.Extension):
return "audio"
case hasExt(videoExtensions, e.Extension):
return "video"
```
## 5. External Utility Detection
File: [`internal/fs/preview.go`](internal/fs/preview.go) — new helper functions
```go
func findTool(name string) string {
path, err := exec.LookPath(name)
if err != nil {
return ""
}
return path
}
```
Add `"os/exec"` to imports.
## 6. Modified `BuildPreview()` Flow
File: [`internal/fs/preview.go`](internal/fs/preview.go) — `BuildPreview()` (line 73)
Insert the new checks after the image detection block (after line 131) and before the `IsBinarySample()` check:
```go
// PDF preview via pdftotext
if hasExt(pdfExtensions, entry.Extension) {
return buildPDFPreview(entry, options, preview)
}
// Audio preview via ffprobe
if hasExt(audioExtensions, entry.Extension) {
return buildAudioPreview(entry, options, preview)
}
// Video preview via ffprobe
if hasExt(videoExtensions, entry.Extension) {
return buildVideoPreview(entry, options, preview)
}
```
This placement ensures:
- Images are still detected by magic bytes (works for any extension)
- PDF/audio/video get their custom handling
- Files that don't match any category fall through to the existing binary/text detection
## 7. New Preview Builder Functions
File: [`internal/fs/preview.go`](internal/fs/preview.go) — new functions
### 7a. `buildPDFPreview()`
```go
func buildPDFPreview(entry Entry, options PreviewOptions, base Preview) Preview {
pdftotext := findTool("pdftotext")
if pdftotext == "" {
base.Kind = PreviewKindBinary
base.Body = "PDF file detected.\nInstall poppler-utils (pdftotext) for text preview."
base.PlainBody = base.Body
return base
}
// Extract text
cmd := exec.Command(pdftotext, "-layout", "-nopgbrk", entry.Path, "-")
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("pdftotext error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
text := string(out)
if len(text) > int(options.MaxPreviewBytes) {
text = text[:options.MaxPreviewBytes]
}
// Get page count via pdfinfo if available
pdfinfo := findTool("pdfinfo")
if pdfinfo != "" {
infoCmd := exec.Command(pdfinfo, entry.Path)
if infoOut, err := infoCmd.Output(); err == nil {
for _, line := range strings.Split(string(infoOut), "\n") {
if strings.HasPrefix(strings.ToLower(line), "pages:") {
base.Metadata.PageCount = strings.TrimSpace(strings.TrimPrefix(line, "Pages:"))
break
}
}
}
}
base.Kind = PreviewKindPDF
base.PlainBody = text
base.Body = highlightText(entry.Path, text, options.ThemeName)
return base
}
```
### 7b. `buildAudioPreview()`
```go
func buildAudioPreview(entry Entry, options PreviewOptions, base Preview) Preview {
ffprobe := findTool("ffprobe")
if ffprobe == "" {
base.Kind = PreviewKindBinary
base.Body = "Audio file detected.\nInstall ffmpeg (ffprobe) for metadata preview."
base.PlainBody = base.Body
return base
}
// Use ffprobe to extract metadata as JSON
cmd := exec.Command(ffprobe,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
entry.Path,
)
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("ffprobe error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
var info struct {
Format struct {
Duration string `json:"duration"`
Bitrate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
SampleRate string `json:"sample_rate"`
Channels int `json:"channels"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &info); err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("Could not parse ffprobe output:\n\n%s", err)
base.PlainBody = base.Body
return base
}
// Format duration
if info.Format.Duration != "" {
if secs, err := strconv.ParseFloat(info.Format.Duration, 64); err == nil {
mins := int(secs) / 60
secsRem := int(secs) % 60
base.Metadata.Duration = fmt.Sprintf("%d:%02d", mins, secsRem)
}
}
if info.Format.Bitrate != "" {
if bps, err := strconv.ParseInt(info.Format.Bitrate, 10, 64); err == nil {
base.Metadata.Bitrate = fmt.Sprintf("%d kbps", bps/1000)
}
}
for _, stream := range info.Streams {
if stream.CodecType == "audio" {
base.Metadata.AudioCodec = stream.CodecName
if stream.SampleRate != "" {
base.Metadata.SampleRate = stream.SampleRate + " Hz"
}
switch stream.Channels {
case 1:
base.Metadata.Channels = "mono"
case 2:
base.Metadata.Channels = "stereo"
case 6:
base.Metadata.Channels = "5.1"
case 8:
base.Metadata.Channels = "7.1"
default:
base.Metadata.Channels = fmt.Sprintf("%d ch", stream.Channels)
}
break
}
}
// Build a rich metadata body
var lines []string
lines = append(lines, fmt.Sprintf(" Duration: %s", base.Metadata.Duration))
if base.Metadata.Bitrate != "" {
lines = append(lines, fmt.Sprintf(" Bitrate: %s", base.Metadata.Bitrate))
}
if base.Metadata.AudioCodec != "" {
lines = append(lines, fmt.Sprintf(" Codec: %s", base.Metadata.AudioCodec))
}
if base.Metadata.SampleRate != "" {
lines = append(lines, fmt.Sprintf(" Rate: %s", base.Metadata.SampleRate))
}
if base.Metadata.Channels != "" {
lines = append(lines, fmt.Sprintf(" Channels: %s", base.Metadata.Channels))
}
base.Kind = PreviewKindAudio
base.Body = fmt.Sprintf("🎵 Audio File\n\n%s", strings.Join(lines, "\n"))
base.PlainBody = fmt.Sprintf("Audio File\n\nDuration: %s\nBitrate: %s\nCodec: %s\nRate: %s\nChannels: %s",
base.Metadata.Duration, base.Metadata.Bitrate, base.Metadata.AudioCodec,
base.Metadata.SampleRate, base.Metadata.Channels)
return base
}
```
### 7c. `buildVideoPreview()`
```go
func buildVideoPreview(entry Entry, options PreviewOptions, base Preview) Preview {
ffprobe := findTool("ffprobe")
if ffprobe == "" {
base.Kind = PreviewKindBinary
base.Body = "Video file detected.\nInstall ffmpeg (ffprobe) for metadata preview."
base.PlainBody = base.Body
return base
}
// Use ffprobe to extract metadata as JSON
cmd := exec.Command(ffprobe,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
entry.Path,
)
out, err := cmd.Output()
if err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("ffprobe error:\n\n%s", err)
base.PlainBody = base.Body
return base
}
var info struct {
Format struct {
Duration string `json:"duration"`
Bitrate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &info); err != nil {
base.Kind = PreviewKindError
base.Body = fmt.Sprintf("Could not parse ffprobe output:\n\n%s", err)
base.PlainBody = base.Body
return base
}
// Format duration
if info.Format.Duration != "" {
if secs, err := strconv.ParseFloat(info.Format.Duration, 64); err == nil {
hrs := int(secs) / 3600
mins := (int(secs) % 3600) / 60
secsRem := int(secs) % 60
if hrs > 0 {
base.Metadata.Duration = fmt.Sprintf("%d:%02d:%02d", hrs, mins, secsRem)
} else {
base.Metadata.Duration = fmt.Sprintf("%d:%02d", mins, secsRem)
}
}
}
if info.Format.Bitrate != "" {
if bps, err := strconv.ParseInt(info.Format.Bitrate, 10, 64); err == nil {
base.Metadata.Bitrate = fmt.Sprintf("%d kbps", bps/1000)
}
}
for _, stream := range info.Streams {
switch stream.CodecType {
case "video":
base.Metadata.VideoCodec = stream.CodecName
if stream.Width > 0 && stream.Height > 0 {
base.Metadata.Dimensions = fmt.Sprintf("%dx%d", stream.Width, stream.Height)
}
case "audio":
if base.Metadata.AudioCodec == "" {
base.Metadata.AudioCodec = stream.CodecName
}
}
}
// Build a rich metadata body
var lines []string
lines = append(lines, fmt.Sprintf(" Duration: %s", base.Metadata.Duration))
if base.Metadata.Bitrate != "" {
lines = append(lines, fmt.Sprintf(" Bitrate: %s", base.Metadata.Bitrate))
}
if base.Metadata.VideoCodec != "" {
lines = append(lines, fmt.Sprintf(" Video: %s", base.Metadata.VideoCodec))
}
if base.Metadata.Dimensions != "" {
lines = append(lines, fmt.Sprintf(" Resolution: %s", base.Metadata.Dimensions))
}
if base.Metadata.AudioCodec != "" {
lines = append(lines, fmt.Sprintf(" Audio: %s", base.Metadata.AudioCodec))
}
base.Kind = PreviewKindVideo
base.Body = fmt.Sprintf("🎬 Video File\n\n%s", strings.Join(lines, "\n"))
base.PlainBody = fmt.Sprintf("Video File\n\nDuration: %s\nBitrate: %s\nVideo: %s\nResolution: %s\nAudio: %s",
base.Metadata.Duration, base.Metadata.Bitrate, base.Metadata.VideoCodec,
base.Metadata.Dimensions, base.Metadata.AudioCodec)
return base
}
```
## 8. UI: Update `renderMetadata()`
File: [`internal/ui/model.go`](internal/ui/model.go) — `renderMetadata()` (line 2714)
Add new metadata fields to the right column after the image info block:
```go
// After: if meta.ImageFormat != "" { ... }
if meta.Duration != "" {
rightRows = append(rightRows, fmt.Sprintf("duration: %s", meta.Duration))
}
if meta.Bitrate != "" {
rightRows = append(rightRows, fmt.Sprintf("bitrate: %s", meta.Bitrate))
}
if meta.AudioCodec != "" {
rightRows = append(rightRows, fmt.Sprintf("audio: %s", meta.AudioCodec))
}
if meta.VideoCodec != "" {
rightRows = append(rightRows, fmt.Sprintf("video: %s", meta.VideoCodec))
}
if meta.Dimensions != "" {
rightRows = append(rightRows, fmt.Sprintf("resolution: %s", meta.Dimensions))
}
if meta.SampleRate != "" {
rightRows = append(rightRows, fmt.Sprintf("rate: %s", meta.SampleRate))
}
if meta.Channels != "" {
rightRows = append(rightRows, fmt.Sprintf("channels: %s", meta.Channels))
}
if meta.PageCount != "" {
rightRows = append(rightRows, fmt.Sprintf("pages: %s", meta.PageCount))
}
```
## 9. UI: Update `previewIcon()`
File: [`internal/ui/model.go`](internal/ui/model.go) — `previewIcon()` (line 3545)
Add new Nerd Font icons:
```go
case vfs.PreviewKindPDF:
return "󰷉" // nf-oct-file-pdf
case vfs.PreviewKindAudio:
return "󰋋" // nf-custom-audio-file
case vfs.PreviewKindVideo:
return "󰋲" // nf-custom-video-file
```
And ASCII fallback:
```go
case "pdf":
return "[P]"
case "audio":
return "[A]"
case "video":
return "[V]"
```
## 10. UI: Update `syncImageOverlay()`
File: [`internal/ui/model.go`](internal/ui/model.go) — `syncImageOverlay()` (line 4225)
The overlay should only show for `PreviewKindImage`, which it already checks. No change needed — video files should NOT use the image overlay since we don't have video thumbnail support yet.
## 11. Files Changed Summary
| File | Changes |
|------|---------|
| [`internal/fs/entry.go`](internal/fs/entry.go) | Add `pdfExtensions`, `audioExtensions`, `videoExtensions` maps; update `Category()` method |
| [`internal/fs/preview.go`](internal/fs/preview.go) | Add `PreviewKindPDF/Audio/Video` constants; add fields to `Metadata`; add `findTool()`, `buildPDFPreview()`, `buildAudioPreview()`, `buildVideoPreview()`; modify `BuildPreview()` insertion point; add `"os/exec"`, `"encoding/json"` imports |
| [`internal/ui/model.go`](internal/ui/model.go) | Update `renderMetadata()` with new fields; update `previewIcon()` with new icons |
## 12. Edge Cases & Considerations
- **Missing external tool**: If `pdftotext` or `ffprobe` is not installed, the user sees a helpful message telling them which package to install, and the preview falls back to `PreviewKindBinary` (same as before).
- **Large PDFs**: Text extraction respects `MaxPreviewBytes` limit — truncated output is still highlighted.
- **Corrupt files**: `ffprobe` and `pdftotext` handle errors gracefully; any non-zero exit returns `PreviewKindError` with the error message.
- **Performance**: External processes are launched synchronously in the preview command (which already runs in a goroutine via `loadPreviewCmd()`), so the UI remains responsive.
- **No new dependencies**: All tools are invoked via `os/exec` (stdlib). No Go modules needed.
## 13. Test Plan
1. Open `test.pdf` — verify text is extracted and syntax-highlighted, page count shown in metadata
2. Open `test.mp3` — verify duration, bitrate, codec, sample rate, channels shown
3. Open `test.flac` — same as above
4. Open `test.mp4` — verify duration, bitrate, video codec, resolution, audio codec shown
5. Open PDF/audio/video on a system without `pdftotext`/`ffprobe` — verify fallback message
6. Verify `go build ./...` and `go vet ./...` pass

View file

@ -0,0 +1,44 @@
# Feature Roadmap — vcom
## Выбранные фичи (по приоритету)
### 1. Поиск/фильтрация файлов (`/`)
- [ ] **Filter mode**: при нажатии `/` открывается текстовый инпут внизу экрана (поверх footer, как модальное окно)
- [ ] Фильтрация `[]Entry` в активной панели на лету по `strings.Contains`/fuzzy-match
- [ ] Подсветка совпадений в строках (изменить `renderEntryRow` — передать query, подсветить matched part)
- [ ] `Esc` — выход из filter mode, восстановление полного списка
- [ ] `Enter` — зафиксировать фильтр (оставить отфильтрованный список), выход из filter mode
- [ ] При смене директории фильтр сбрасывается
### 2. Bulk rename (массовое переименование)
- [ ] Выделить файлы (`Shift+↑/↓`), нажать `Ctrl+R` (новая клавиша)
- [ ] Модальное окно с текстовым полем для паттерна: `prefix_%N.ext`
- [ ] Превью результата (старое имя → новое имя)
- [ ] Выполнить rename для всех выделенных
### 3. Корзина (trash support)
- [ ] `Delete/F8` — перемещать в `~/.local/share/Trash/` по freedesktop spec
- [ ] `Shift+Delete` —永久ное удаление (как сейчас)
- [ ] `browser.confirm_delete` применяется к永久ному удалению
- [ ] Новая опция конфига: `behavior.use_trash = true` (default: true)
### 4. Directory history (назад/вперед)
- [ ] `[]string` стек истории на каждую панель
- [ ] `Alt+←` / `Alt+→` — навигация назад/вперед
- [ ] При переходе в новую директорию (Enter, Backspace, клик) — push в history
- [ ] При навигации по истории — не создавать новые записи
### 5. Расширенный превью форматов
- [ ] PDF — извлечение текста через `pdftotext` (если доступен)
- [ ] Аудио — метаданные через `ffprobe` (битрейт, длительность, кодек)
- [ ] Видео — метаданные + превью через `ffmpegthumbnailer`/`ffprobe`
- [ ] Fallback если утилита не установлена
---
## Процесс
1. Каждая фича реализуется в отдельной ветке `feature/N-имя`
2. После реализации — commit + push
3. После апрува — merge в main
4. Порядок: 1 → 4 → 3 → 2 → 5 (от простого к сложному)

View file

@ -0,0 +1,88 @@
# Feature Plan: Mirror Pane & Per-Directory Cursor Memory
## Feature 1: Mirror active pane directory to opposite pane
### Keybinding research
- **Midnight Commander (MC)**: `Alt-i` — makes the other panel equal to current directory
- **Total Commander**: `Ctrl-PgDn` opens directory under cursor in opposite panel
- **Far Manager**: `Ctrl-Left/Right` — opens in opposite panel
**Recommendation**: Bind to `p` (for "Pane"). `Ctrl-i` sends the same byte as `Tab` (ASCII 0x09) so it conflicts with Switch. `p` is free, accessible, and "Pane" reflects the meaning.
### How it works
1. Read active pane's current path (including remote mount state)
2. Apply that path to the passive (opposite) pane
3. If active is in a remote mount, also clone the remote mount stack to the passive pane
4. Reload both panes to reflect the change
### Code changes
#### [`internal/ui/keymap.go`](internal/ui/keymap.go)
- Add `Mirror` key binding field to `KeyMap` struct
- Bind to `"p"` with help text `"p"` / `"mirror pane"`
#### [`internal/ui/model.go`](internal/ui/model.go)
- Add handler `handleMirrorPane()`:
1. Get active pane path and remote mount state
2. Switch passive pane to match (copy remote stack if applicable)
3. Reload passive pane (using `reloadPane` or `reloadRemotePane`)
4. Set status: `"Mirrored: <path>"`
- Add key match case before the `default` switch
---
## Feature 2: Per-directory cursor position memory (session scope)
### Current state
- `enterSelected()` saves `pane.Path` to history for back-nav, then reloads target dir with `selected.Name` as preserve key — but `selected.Name` is the name of the directory being entered, not a cursor anchor
- `goParent()` pops history and reloads parent using the child directory name as preserve — cursor lands on the directory we came from
- **Missing**: When navigating back into a previously-visited directory, there's no saved cursor position for it
### Design
Add a `cursorMemory` field to `BrowserPane` — a `map[string]string` mapping **directory path → last selected entry display name**.
This integrates cleanly with the existing `SetEntries(entries, preserveKey)` / `FindSelected()` infrastructure.
### Flow
```
enterSelected() / enterRemoteDir():
1. Save to cursorMemory: pane.Path → selected entry's Name
2. Push history (existing)
3. Set path, reload (existing)
4. The reload already uses preserve=selected.Name for the new dir,
but cursorMemory will help when coming back later
reloadPane() / reloadRemotePane():
1. Try preserve key first (existing behavior)
2. If preserve is empty, check cursorMemory[pane.Path]
and use that as preserveKey instead
```
### Code changes
#### [`internal/ui/pane.go`](internal/ui/pane.go)
- Add field to `BrowserPane`: `CursorMemory map[string]string`
- Method `SaveCursor(path string, name string)` — stores cursor position
- Method `LoadCursor(path string) string` — retrieves saved cursor
#### [`internal/ui/model.go`](internal/ui/model.go)
- In `enterSelected()` (line ~1886): after `pane.PushHistory(pane.Path)`, add `pane.SaveCursor(pane.Path, selected.Name)`
- In `enterRemoteDir()` (line ~5726): same save logic
- In `goParent()`: save cursor before navigating away (already partially done via history)
- In `reloadPane()` (line ~1672): after existing preserve logic, if no preserve key and directory has cursor memory, use it
- In `reloadRemotePane()` (line ~5535): same fallback
---
## Files modified
| File | Changes |
|------|---------|
| `internal/ui/keymap.go` | Add `Mirror` field, binding `p` |
| `internal/ui/pane.go` | Add `CursorMemory` field + methods |
| `internal/ui/model.go` | Add `handleMirrorPane()`, save/restore cursor in navigation methods |
## Not changed
- `internal/fs/` — storage layer unaffected
- `internal/config/` — no config changes needed
- `internal/theme/` — no theme changes needed

View file

@ -0,0 +1,193 @@
# Plan: Theme Selector Dialog with Live Preview
## Summary
Replace the current `cycleTheme()` (simple cycle through themes on `t`) with a modal dialog that shows all themes, their base colors, supports live preview via Up/Down navigation, and commit/revert on Enter/Esc.
## Changes
### 1. `internal/ui/model.go` — New modal kind
Add to `modalKind` const (around line 34):
```go
modalThemeSelect
```
### 2. `internal/ui/model.go` — New theme selector state
Add new struct and field to `Model`:
```go
type themeSelectorState struct {
names []string // all theme names in order
cursor int // current cursor index in the list
original string // the theme name before opening dialog (for Esc revert)
}
type Model struct {
// ... existing fields ...
themeSelector *themeSelectorState // nil when not in theme selector dialog
}
```
### 3. `internal/ui/model.go` — New handler `openThemeSelector()`
Replace the `cycleTheme()` call with `openThemeSelector()`:
1. Read current theme name from `m.cfg.UI.Theme`
2. Get all theme names via `theme.Names()`
3. Find current theme index in the list
4. Create `themeSelectorState` with names, cursor=current index, original=current theme
5. Set `m.modal.kind = modalThemeSelect`
6. Set modal title/body (body may be empty since list is rendered in `renderThemeSelectModal`)
The `t` key match (`case key.Matches(msg, m.keys.CycleTheme)`) now calls `m.openThemeSelector()` instead of `m.cycleTheme()`.
### 4. `internal/ui/model.go` — Modal key handling
Add a new case in `handleModalKey()` for `modalThemeSelect`:
```go
case modalThemeSelect:
switch {
case msg.String() == "up" || msg.String() == "k":
// Move cursor up, clamp to 0, apply theme preview
m.themeSelector.cursor--
if m.themeSelector.cursor < 0 { m.themeSelector.cursor = 0 }
m.applyThemePreview(m.themeSelector.names[m.themeSelector.cursor])
return m, nil
case msg.String() == "down" || msg.String() == "j":
// Move cursor down, clamp to len-1, apply theme preview
m.themeSelector.cursor++
if m.themeSelector.cursor >= len(m.themeSelector.names) {
m.themeSelector.cursor = len(m.themeSelector.names) - 1
}
m.applyThemePreview(m.themeSelector.names[m.themeSelector.cursor])
return m, nil
case key.Matches(msg, m.keys.Confirm): // Enter
// Apply selected theme and save
selected := m.themeSelector.names[m.themeSelector.cursor]
m.finalizeTheme(selected)
m.themeSelector = nil
m.modal = modalState{}
m.status = fmt.Sprintf("Theme: %s", selected)
return m, nil
case msg.String() == "esc":
// Revert to original theme
m.applyThemePreview(m.themeSelector.original)
m.themeSelector = nil
m.modal = modalState{}
m.status = "Theme unchanged"
return m, nil
}
```
### 5. `internal/ui/model.go` — applyThemePreview helper
A new method that applies a theme palette to `m.palette` **without saving to config**:
```go
func (m *Model) applyThemePreview(name string) {
palette, err := theme.Resolve(name)
if err != nil {
return // silently ignore resolve errors during preview
}
m.palette = palette
// Don't update m.cfg.UI.Theme or save — that's only on Enter
}
```
### 6. `internal/ui/model.go` — finalizeTheme helper
A new method that applies the theme AND saves to config:
```go
func (m *Model) finalizeTheme(name string) {
palette, err := theme.Resolve(name)
if err != nil {
m.status = err.Error()
return
}
m.cfg.UI.Theme = name
m.palette = palette
savedPath, saveErr := config.Save(m.cfg, m.configPath)
if saveErr != nil {
m.status = fmt.Sprintf("Theme: %s (save failed: %v)", name, saveErr)
return
}
m.configPath = savedPath
}
```
### 7. `internal/ui/model.go` — renderThemeSelectModal
Create a new render function `renderThemeSelectModal()`:
```go
func renderThemeSelectModal(m Model, palette theme.Palette, width int) string {
outerWidth := max(width, 8)
contentWidth := max(outerWidth-6, 1)
// Styles
titleStyle := lipgloss.NewStyle()...
box := lipgloss.NewStyle()...
// Build theme list rows
lines := []string{titleStyle.Render("Select Theme"), spacer}
lines = append(lines, instructions)
lines = append(lines, spacer)
for i, name := range m.themeSelector.names {
resolved, err := theme.Resolve(name)
// skip if error
// Selection indicator + theme name
// Color swatches: Background, Panel, Accent, Text, Selection
// Highlight current item
}
return box.Render(strings.Join(lines, "\n"))
}
```
Each row shows:
- Cursor indicator (`▸` or ` `)
- Theme name
- Color swatches as small colored blocks: Background, Panel, Accent, Text, Selection
- Currently selected item is highlighted with `Selection` background color
### 8. `internal/ui/model.go` — Update View dispatch
Add a new condition in `renderModal()` (around line 3698) to dispatch to `renderThemeSelectModal` when `modalKind == modalThemeSelect`:
```go
if m.modal.kind == modalThemeSelect {
return renderThemeSelectModal(m, palette, width)
}
```
### 9. `internal/ui/model.go` — Remove old cycleTheme (optional)
The old `cycleTheme()` method can be kept for backward compatibility or removed. The `t` key will now open the dialog instead.
### 10. Help dialog update (optional)
Update the help dialog to reflect the new behavior: `"t open theme selector"` instead of `"t cycle theme"`.
## Files modified
| File | Changes |
|------|---------|
| `internal/ui/model.go` | Add `modalThemeSelect` kind, `themeSelectorState` struct, `openThemeSelector()`, `applyThemePreview()`, `finalizeTheme()`, key handling in `handleModalKey`, `renderThemeSelectModal()`, update `renderModal()` dispatch |
| (none else) | Theme data, config saving, keymap — all remain unchanged |
## Key design points
1. **Live preview**: Every Up/Down key press instantly resolves the new theme palette and applies it to `m.palette`. The user sees the full UI change in real time.
2. **Safe revert**: On Esc, the original theme (saved in `themeSelectorState.original`) is restored.
3. **Config save only on Enter**: Only `finalizeTheme()` persists to config file.
4. **Color swatches**: Each theme row shows 5 small colored blocks (Background, Panel, Accent, Text, Selection) so the user can visually compare themes.
5. **Clean state management**: `themeSelector` is `nil` when the dialog is closed, making it easy to check state.
## Not changed
- `internal/theme/` — storage layer unchanged
- `internal/config/` — no config changes
- `internal/ui/keymap.go``t` binding unchanged, only behavior changes
- `internal/ui/pane.go` — no changes

View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: $0 <version>" >&2
exit 1
fi
version="$1"
pkgroot="target/debian/pkgroot"
outdir="target/debian"
rm -rf "$pkgroot"
mkdir -p \
"$pkgroot/DEBIAN" \
"$pkgroot/usr/bin" \
"$pkgroot/usr/share/doc/vcom" \
"$pkgroot/usr/share/licenses/vcom"
install -Dm755 "target/release/vcom" "$pkgroot/usr/bin/vcom"
install -Dm644 "README.md" "$pkgroot/usr/share/doc/vcom/README.md"
install -Dm644 "vcom.toml" "$pkgroot/usr/share/doc/vcom/vcom.toml"
install -Dm644 "LICENSE" "$pkgroot/usr/share/licenses/vcom/LICENSE"
cat > "$pkgroot/DEBIAN/control" <<EOF
Package: vcom
Version: ${version}
Section: utils
Priority: optional
Architecture: amd64
Maintainer: Roman Vrubel <roman@vrubel.dev>
Depends: ueberzug | ueberzugpp
Description: Terminal file manager inspired by Midnight Commander
A two-pane terminal file manager with inspect mode and text previews.
EOF
dpkg-deb --build "$pkgroot" "$outdir/vcom_${version}_amd64.deb"

45
src/vcom-0.2.5/vcom.toml Normal file
View file

@ -0,0 +1,45 @@
[startup]
left_path = ''
right_path = ''
[ui]
app_title = 'vcom'
theme = 'github-dark'
icon_mode = 'auto'
show_title_bar = true
show_footer = true
border = 'rounded'
path_display = 'smart'
pane_gap = 1
center_width_percent = 30
[browser]
show_hidden = true
dirs_first = true
human_readable_size = true
[browser.sort]
by = 'name'
reverse = false
[browser.columns]
name = true
size = true
modified = true
created = false
permissions = false
extension = false
[preview]
show_metadata = true
wrap_text = false
max_preview_bytes = 65536
directory_preview_limit = 80
[behavior]
confirm_delete = true
confirm_overwrite = true
calculate_dir_size_on_space = true
follow_symlinks = false
auto_refresh = true
auto_refresh_interval = 5

View file

@ -0,0 +1,17 @@
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.xml]
indent_style = space
indent_size = 2
insert_final_newline = false
[*.yml]
indent_style = space
indent_size = 2

View file

@ -0,0 +1,28 @@
# Binaries for programs and plugins
.git
.idea
.vscode
.hermit
*.exe
*.dll
*.so
*.dylib
/cmd/chroma/chroma
# Test binary, build with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736
.glide/
_models/
_examples/
*.min.*
build/
cmd/chromad/static/chroma.wasm
cmd/chromad/static/wasm_exec.js

View file

@ -0,0 +1,89 @@
run:
tests: true
output:
print-issued-lines: false
linters:
enable-all: true
disable:
- lll
- gocyclo
- dupl
- gochecknoglobals
- funlen
- godox
- wsl
- gocognit
- nolintlint
- testpackage
- godot
- nestif
- paralleltest
- nlreturn
- cyclop
- gci
- gofumpt
- errorlint
- exhaustive
- wrapcheck
- stylecheck
- thelper
- nonamedreturns
- revive
- dupword
- exhaustruct
- varnamelen
- forcetypeassert
- ireturn
- maintidx
- govet
- testableexamples
- musttag
- depguard
- goconst
- perfsprint
- mnd
- predeclared
- recvcheck
- tenv
- err113
linters-settings:
gocyclo:
min-complexity: 10
dupl:
threshold: 100
goconst:
min-len: 8
min-occurrences: 3
forbidigo:
#forbid:
# - (Must)?NewLexer$
exclude_godoc_examples: false
issues:
exclude-dirs:
- _examples
max-per-linter: 0
max-same: 0
exclude-use-default: false
exclude:
# Captured by errcheck.
- '^(G104|G204):'
# Very commonly not checked.
- 'Error return value of .(.*\.Help|.*\.MarkFlagRequired|(os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*printf?|os\.(Un)?Setenv). is not checked'
- 'exported method (.*\.MarshalJSON|.*\.UnmarshalJSON|.*\.EntityURN|.*\.GoString|.*\.Pos) should have comment or be unexported'
- 'composite literal uses unkeyed fields'
- 'declaration of "err" shadows declaration'
- 'should not use dot imports'
- 'Potential file inclusion via variable'
- 'should have comment or be unexported'
- 'comment on exported var .* should be of the form'
- 'at least one file in a package should have a package comment'
- 'string literal contains the Unicode'
- 'methods on the same type should have the same receiver name'
- '_TokenType_name should be _TokenTypeName'
- '`_TokenType_map` should be `_TokenTypeMap`'
- 'rewrite if-else to switch statement'

View file

@ -0,0 +1,34 @@
project_name: chroma
release:
github:
owner: alecthomas
name: chroma
brews:
- install: bin.install "chroma"
env:
- CGO_ENABLED=0
builds:
- goos:
- linux
- darwin
- windows
goarch:
- arm64
- amd64
- "386"
goarm:
- "6"
dir: ./cmd/chroma
main: .
ldflags: -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
binary: chroma
archives:
- format: tar.gz
name_template: "{{ .Binary }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
files:
- COPYING
- README*
snapshot:
name_template: SNAPSHOT-{{ .Commit }}
checksum:
name_template: "{{ .ProjectName }}-{{ .Version }}-checksums.txt"

View file

@ -0,0 +1,11 @@
Chroma is a syntax highlighting library, tool and web playground for Go. It is based on Pygments and includes importers for it, so most of the same concepts from Pygments apply to Chroma.
This project is written in Go, uses Hermit to manage tooling, and Just for helper commands. Helper scripts are in ./scripts.
Language definitions are XML files defined in ./lexers/embedded/*.xml.
Styles/themes are defined in ./styles/*.xml.
The CLI can be run with `chroma`.
The web playground can be run with `chromad --csrf-key=moo`. It blocks, so should generally be run in the background. It also does not hot reload, so has to be manually restarted. The playground has two modes - for local development it uses the server itself to render, while for production running `just chromad` will compile ./cmd/libchromawasm into a WASM module that is bundled into `chromad`.

View file

@ -0,0 +1,24 @@
VERSION = %(git describe --tags --dirty --always)%
export CGOENABLED = 0
tokentype_enumer.go: types.go
build: go generate
# Regenerate the list of lexers in the README
README.md: lexers/*.go lexers/*/*.xml table.py
build: ./table.py
-clean
implicit %{1}%{2}.min.%{3}: **/*.{css,js}
build: esbuild --bundle %{IN} --minify --outfile=%{OUT}
implicit build/%{1}: cmd/*
cd cmd/%{1}
inputs: cmd/%{1}/**/* **/*.go
build: go build -ldflags="-X 'main.version=%{VERSION}'" -o ../../build/%{1} .
#upload: chromad
# build:
# scp chromad root@swapoff.org:
# ssh root@swapoff.org 'install -m755 ./chromad /srv/http/swapoff.org/bin && service chromad restart'
# touch upload

View file

@ -0,0 +1,19 @@
Copyright (C) 2017 Alec Thomas
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,64 @@
# Multi-stage Dockerfile for chromad Go application using Hermit-managed tools
# Build stage
FROM ubuntu:24.04 AS builder
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
git \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Set working directory
WORKDIR /app
# Copy the entire project (including bin directory with Hermit tools)
COPY . .
# Make Hermit tools executable and add to PATH
ENV PATH="/app/bin:${PATH}"
# Set Go environment variables for static compilation
ENV CGO_ENABLED=0
ENV GOOS=linux
ENV GOARCH=amd64
# Build the application using just
RUN just chromad
# Runtime stage
FROM alpine:3.23 AS runtime
# Install ca-certificates for HTTPS requests
RUN apk --no-cache add ca-certificates curl
# Create a non-root user
RUN addgroup -g 1001 chromad && \
adduser -D -s /bin/sh -u 1001 -G chromad chromad
# Set working directory
WORKDIR /app
# Copy the binary from build stage
COPY --from=builder /app/build/chromad /app/chromad
# Change ownership to non-root user
RUN chown chromad:chromad /app/chromad
# Switch to non-root user
USER chromad
# Expose port (default is 8080, but can be overridden via PORT env var)
EXPOSE 8080
# Set default environment variables
ENV PORT=8080
ENV CHROMA_CSRF_KEY="testtest"
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -fsSL http://127.0.0.1:8080/ > /dev/null
# Run the application
CMD ["sh", "-c", "./chromad --csrf-key=$CHROMA_CSRF_KEY --bind=0.0.0.0:$PORT"]

View file

@ -0,0 +1,55 @@
set positional-arguments := true
set shell := ["bash", "-c"]
version := `git describe --tags --dirty --always`
export GOOS := env("GOOS", "linux")
export GOARCH := env("GOARCH", "amd64")
_help:
@just -l
# Generate README.md from lexer definitions
readme:
#!/usr/bin/env bash
GOOS= GOARCH= ./table.py
# Generate tokentype_string.go
tokentype-string:
go generate
# Format JavaScript files
format-js:
biome format --write cmd/chromad/static/index.js cmd/chromad/static/chroma.js
# Build chromad binary
chromad: wasm-exec chroma-wasm
#!/usr/bin/env bash
rm -rf build
mk cmd/chromad/static/index.min.js : cmd/chromad/static/{index,chroma}.js -- \
esbuild --platform=browser --format=esm --bundle cmd/chromad/static/index.js --minify --external:./wasm_exec.js --outfile=cmd/chromad/static/index.min.js
mk cmd/chromad/static/index.min.css : cmd/chromad/static/index.css -- \
esbuild --bundle cmd/chromad/static/index.css --minify --outfile=cmd/chromad/static/index.min.css
cd cmd/chromad && CGOENABLED=0 go build -ldflags="-X 'main.version={{ version }}'" -o ../../build/chromad .
# Copy wasm_exec.js from TinyGo
wasm-exec:
#!/usr/bin/env bash
tinygoroot=$(tinygo env TINYGOROOT)
mk cmd/chromad/static/wasm_exec.js : "$tinygoroot/targets/wasm_exec.js" -- \
install -m644 "$tinygoroot/targets/wasm_exec.js" cmd/chromad/static/wasm_exec.js
# Build WASM binary
chroma-wasm:
#!/usr/bin/env bash
if type tinygo > /dev/null 2>&1; then
mk cmd/chromad/static/chroma.wasm : cmd/libchromawasm/main.go -- \
tinygo build -no-debug -target wasm -o cmd/chromad/static/chroma.wasm cmd/libchromawasm/main.go
else
mk cmd/chromad/static/chroma.wasm : cmd/libchromawasm/main.go -- \
GOOS=js GOARCH=wasm go build -o cmd/chromad/static/chroma.wasm cmd/libchromawasm/main.go
fi
# Upload chromad to server
upload: chromad
scp build/chromad root@swapoff.org:
ssh root@swapoff.org 'install -m755 ./chromad /srv/http/swapoff.org/bin && service chromad restart'

View file

@ -0,0 +1,307 @@
![Chroma](chroma.jpg)
# A general purpose syntax highlighter in pure Go
[![Go Reference](https://pkg.go.dev/badge/github.com/alecthomas/chroma/v2.svg)](https://pkg.go.dev/github.com/alecthomas/chroma/v2) [![CI](https://github.com/alecthomas/chroma/actions/workflows/ci.yml/badge.svg)](https://github.com/alecthomas/chroma/actions/workflows/ci.yml) [![Slack chat](https://img.shields.io/static/v1?logo=slack&style=flat&label=slack&color=green&message=gophers)](https://invite.slack.golangbridge.org/)
Chroma takes source code and other structured text and converts it into syntax
highlighted HTML, ANSI-coloured text, etc.
Chroma is based heavily on [Pygments](http://pygments.org/), and includes
translators for Pygments lexers and styles.
## Table of Contents
<!-- TOC -->
1. [Supported languages](#supported-languages)
2. [Try it](#try-it)
3. [Using the library](#using-the-library)
1. [Quick start](#quick-start)
2. [Identifying the language](#identifying-the-language)
3. [Formatting the output](#formatting-the-output)
4. [The HTML formatter](#the-html-formatter)
4. [More detail](#more-detail)
1. [Lexers](#lexers)
2. [Formatters](#formatters)
3. [Styles](#styles)
5. [Command-line interface](#command-line-interface)
6. [Testing lexers](#testing-lexers)
7. [What's missing compared to Pygments?](#whats-missing-compared-to-pygments)
<!-- /TOC -->
## Supported languages
| Prefix | Language
| :----: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| A | ABAP, ABNF, ActionScript, ActionScript 3, Ada, Agda, AL, Alloy, Angular2, ANTLR, ApacheConf, APL, AppleScript, ArangoDB AQL, Arduino, ArmAsm, ATL, AutoHotkey, AutoIt, Awk
| B | Ballerina, Bash, Bash Session, Batchfile, Beef, BibTeX, Bicep, BlitzBasic, BNF, BQN, Brainfuck
| C | C, C#, C++, C3, Caddyfile, Caddyfile Directives, Cap'n Proto, Cassandra CQL, Ceylon, CFEngine3, cfstatement, ChaiScript, Chapel, Cheetah, Clojure, CMake, COBOL, CoffeeScript, Common Lisp, Coq, Core, Crystal, CSS, CSV, CUE, Cython
| D | D, Dart, Dax, Desktop file, Diff, Django/Jinja, dns, Docker, DTD, Dylan
| E | EBNF, Elixir, Elm, EmacsLisp, Erlang
| F | Factor, Fennel, Fish, Forth, Fortran, FortranFixed, FSharp
| G | GAS, GDScript, GDScript3, Gemtext, Genshi, Genshi HTML, Genshi Text, Gherkin, Gleam, GLSL, Gnuplot, Go, Go HTML Template, Go Template, Go Text Template, GraphQL, Groff, Groovy
| H | Handlebars, Hare, Haskell, Haxe, HCL, Hexdump, HLB, HLSL, HolyC, HTML, HTTP, Hy
| I | Idris, Igor, INI, Io, ISCdhcpd
| J | J, Janet, Java, JavaScript, JSON, JSONata, Jsonnet, Julia, Jungle
| K | Kakoune, Kotlin
| L | Lean4, Lighttpd configuration file, LLVM, lox, Lua
| M | Makefile, Mako, markdown, Markless, Mason, Materialize SQL dialect, Mathematica, Matlab, MCFunction, Meson, Metal, MiniZinc, MLIR, Modelica, Modula-2, Mojo, MonkeyC, MoonScript, MorrowindScript, Myghty, MySQL
| N | NASM, Natural, NDISASM, Newspeak, Nginx configuration file, Nim, Nix, NSIS, Nu
| O | Objective-C, ObjectPascal, OCaml, Octave, Odin, OnesEnterprise, OpenEdge ABL, OpenSCAD, Org Mode
| P | PacmanConf, Perl, PHP, PHTML, Pig, PkgConfig, PL/pgSQL, plaintext, Plutus Core, Pony, PostgreSQL SQL dialect, PostScript, POVRay, PowerQuery, PowerShell, Prolog, Promela, PromQL, properties, Protocol Buffer, Protocol Buffer Text Format, PRQL, PSL, Puppet, Python, Python 2
| Q | QBasic, QML
| R | R, Racket, Ragel, Raku, react, ReasonML, reg, Rego, reStructuredText, Rexx, RGBDS Assembly, Ring, RPGLE, RPMSpec, Ruby, Rust
| S | SAS, Sass, Scala, Scheme, Scilab, SCSS, Sed, Sieve, Smali, Smalltalk, Smarty, SNBT, Snobol, Solidity, SourcePawn, SPARQL, SQL, SquidConf, Standard ML, stas, Stylus, Svelte, Swift, SYSTEMD, systemverilog
| T | TableGen, Tal, TASM, Tcl, Tcsh, Termcap, Terminfo, Terraform, TeX, Thrift, TOML, TradingView, Transact-SQL, Turing, Turtle, Twig, TypeScript, TypoScript, TypoScriptCssData, TypoScriptHtmlData, Typst
| U | ucode
| V | V, V shell, Vala, VB.net, verilog, VHDL, VHS, VimL, vue
| W | WDTE, WebAssembly Text Format, WebGPU Shading Language, WebVTT, Whiley
| X | XML, Xorg
| Y | YAML, YANG
| Z | Z80 Assembly, Zed, Zig
_I will attempt to keep this section up to date, but an authoritative list can be
displayed with `chroma --list`._
## Try it
Try out various languages and styles on the [Chroma Playground](https://swapoff.org/chroma/playground/).
## Using the library
This is version 2 of Chroma, use the import path:
```go
import "github.com/alecthomas/chroma/v2"
```
Chroma, like Pygments, has the concepts of
[lexers](https://github.com/alecthomas/chroma/tree/master/lexers),
[formatters](https://github.com/alecthomas/chroma/tree/master/formatters) and
[styles](https://github.com/alecthomas/chroma/tree/master/styles).
Lexers convert source text into a stream of tokens, styles specify how token
types are mapped to colours, and formatters convert tokens and styles into
formatted output.
A package exists for each of these, containing a global `Registry` variable
with all of the registered implementations. There are also helper functions
for using the registry in each package, such as looking up lexers by name or
matching filenames, etc.
In all cases, if a lexer, formatter or style can not be determined, `nil` will
be returned. In this situation you may want to default to the `Fallback`
value in each respective package, which provides sane defaults.
### Quick start
A convenience function exists that can be used to simply format some source
text, without any effort:
```go
err := quick.Highlight(os.Stdout, someSourceCode, "go", "html", "monokai")
```
### Identifying the language
To highlight code, you'll first have to identify what language the code is
written in. There are three primary ways to do that:
1. Detect the language from its filename.
```go
lexer := lexers.Match("foo.go")
```
2. Explicitly specify the language by its Chroma syntax ID (a full list is available from `lexers.Names()`).
```go
lexer := lexers.Get("go")
```
3. Detect the language from its content.
```go
lexer := lexers.Analyse("package main\n\nfunc main()\n{\n}\n")
```
In all cases, `nil` will be returned if the language can not be identified.
```go
if lexer == nil {
lexer = lexers.Fallback
}
```
At this point, it should be noted that some lexers can be extremely chatty. To
mitigate this, you can use the coalescing lexer to coalesce runs of identical
token types into a single token:
```go
lexer = chroma.Coalesce(lexer)
```
### Formatting the output
Once a language is identified you will need to pick a formatter and a style (theme).
```go
style := styles.Get("swapoff")
if style == nil {
style = styles.Fallback
}
formatter := formatters.Get("html")
if formatter == nil {
formatter = formatters.Fallback
}
```
Then obtain an iterator over the tokens:
```go
contents, err := ioutil.ReadAll(r)
iterator, err := lexer.Tokenise(nil, string(contents))
```
And finally, format the tokens from the iterator:
```go
err := formatter.Format(w, style, iterator)
```
### The HTML formatter
By default the `html` registered formatter generates standalone HTML with
embedded CSS. More flexibility is available through the `formatters/html` package.
Firstly, the output generated by the formatter can be customised with the
following constructor options:
- `Standalone()` - generate standalone HTML with embedded CSS.
- `WithClasses()` - use classes rather than inlined style attributes.
- `ClassPrefix(prefix)` - prefix each generated CSS class.
- `TabWidth(width)` - Set the rendered tab width, in characters.
- `WithLineNumbers()` - Render line numbers (style with `LineNumbers`).
- `WithLinkableLineNumbers()` - Make the line numbers linkable and be a link to themselves.
- `HighlightLines(ranges)` - Highlight lines in these ranges (style with `LineHighlight`).
- `LineNumbersInTable()` - Use a table for formatting line numbers and code, rather than spans.
If `WithClasses()` is used, the corresponding CSS can be obtained from the formatter with:
```go
formatter := html.New(html.WithClasses(true))
err := formatter.WriteCSS(w, style)
```
## More detail
### Lexers
See the [Pygments documentation](http://pygments.org/docs/lexerdevelopment/)
for details on implementing lexers. Most concepts apply directly to Chroma,
but see existing lexer implementations for real examples.
In many cases lexers can be automatically converted directly from Pygments by
using the included Python 3 script `pygments2chroma_xml.py`. I use something like
the following:
```sh
uv run --script _tools/pygments2chroma_xml.py \
pygments.lexers.jvm.KotlinLexer \
> lexers/embedded/kotlin.xml
```
A list of all lexers available in Pygments can be found in [pygments-lexers.txt](https://github.com/alecthomas/chroma/blob/master/pygments-lexers.txt).
### Formatters
Chroma supports HTML output, as well as terminal output in 8 colour, 256 colour, and true-colour.
A `noop` formatter is included that outputs the token text only, and a `tokens`
formatter outputs raw tokens. The latter is useful for debugging lexers.
### Styles
Chroma styles are defined in XML. The style entries use the
[same syntax](http://pygments.org/docs/styles/) as Pygments.
All Pygments styles have been converted to Chroma using the `_tools/style.py`
script.
When you work with one of [Chroma's styles](https://github.com/alecthomas/chroma/tree/master/styles),
know that the `Background` token type provides the default style for tokens. It does so
by defining a foreground color and background color.
For example, this gives each token name not defined in the style a default color
of `#f8f8f8` and uses `#000000` for the highlighted code block's background:
```xml
<entry type="Background" style="#f8f8f2 bg:#000000"/>
```
Also, token types in a style file are hierarchical. For instance, when `CommentSpecial` is not defined, Chroma uses the token style from `Comment`. So when several comment tokens use the same color, you'll only need to define `Comment` and override the one that has a different color.
For a quick overview of the available styles and how they look, check out the [Chroma Style Gallery](https://xyproto.github.io/splash/docs/).
## Command-line interface
A command-line interface to Chroma is included.
Binaries are available to install from [the releases page](https://github.com/alecthomas/chroma/releases).
The CLI can be used as a preprocessor to colorise output of `less(1)`,
see documentation for the `LESSOPEN` environment variable.
The `--fail` flag can be used to suppress output and return with exit status
1 to facilitate falling back to some other preprocessor in case chroma
does not resolve a specific lexer to use for the given file. For example:
```shell
export LESSOPEN='| p() { chroma --fail "$1" || cat "$1"; }; p "%s"'
```
Replace `cat` with your favourite fallback preprocessor.
When invoked as `.lessfilter`, the `--fail` flag is automatically turned
on under the hood for easy integration with [lesspipe shipping with
Debian and derivatives](https://manpages.debian.org/lesspipe#USER_DEFINED_FILTERS);
for that setup the `chroma` executable can be just symlinked to `~/.lessfilter`.
## Projects using Chroma
* [`moor`](https://github.com/walles/moor) is a full-blown pager that colorizes
its input using Chroma
* [Hugo](https://gohugo.io/) is a static site generator that [uses Chroma for syntax
highlighting code examples](https://gohugo.io/content-management/syntax-highlighting/)
## Testing lexers
If you edit some lexers and want to try it, open a shell in `cmd/chromad` and run:
```shell
go run . --csrf-key=securekey
```
A Link will be printed. Open it in your Browser. Now you can test on the Playground with your local changes.
If you want to run the tests and the lexers, open a shell in the root directory and run:
```shell
go test ./lexers
```
When updating or adding a lexer, please add tests. See [lexers/README.md](lexers/README.md) for more.
## What's missing compared to Pygments?
- Quite a few lexers, for various reasons (pull-requests welcome):
- Pygments lexers for complex languages often include custom code to
handle certain aspects, such as Raku's ability to nest code inside
regular expressions. These require time and effort to convert.
- I mostly only converted languages I had heard of, to reduce the porting cost.
- Some more esoteric features of Pygments are omitted for simplicity.
- Though the Chroma API supports content detection, very few languages support them.
I have plans to implement a statistical analyser at some point, but not enough time.

View file

@ -0,0 +1,6 @@
{
"$schema": "https://biomejs.dev/schemas/2.0.5/schema.json",
"formatter": {
"indentStyle": "space"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View file

@ -0,0 +1,35 @@
package chroma
// Coalesce is a Lexer interceptor that collapses runs of common types into a single token.
func Coalesce(lexer Lexer) Lexer { return &coalescer{lexer} }
type coalescer struct{ Lexer }
func (d *coalescer) Tokenise(options *TokeniseOptions, text string) (Iterator, error) {
var prev Token
it, err := d.Lexer.Tokenise(options, text)
if err != nil {
return nil, err
}
return func() Token {
for token := it(); token != (EOF); token = it() {
if len(token.Value) == 0 {
continue
}
if prev == EOF {
prev = token
} else {
if prev.Type == token.Type && len(prev.Value) < 8192 {
prev.Value += token.Value
} else {
out := prev
prev = token
return out
}
}
}
out := prev
prev = EOF
return out
}, nil
}

View file

@ -0,0 +1,192 @@
package chroma
import (
"fmt"
"math"
"strconv"
"strings"
)
// ANSI2RGB maps ANSI colour names, as supported by Chroma, to hex RGB values.
var ANSI2RGB = map[string]string{
"#ansiblack": "000000",
"#ansidarkred": "7f0000",
"#ansidarkgreen": "007f00",
"#ansibrown": "7f7fe0",
"#ansidarkblue": "00007f",
"#ansipurple": "7f007f",
"#ansiteal": "007f7f",
"#ansilightgray": "e5e5e5",
// Normal
"#ansidarkgray": "555555",
"#ansired": "ff0000",
"#ansigreen": "00ff00",
"#ansiyellow": "ffff00",
"#ansiblue": "0000ff",
"#ansifuchsia": "ff00ff",
"#ansiturquoise": "00ffff",
"#ansiwhite": "ffffff",
// Aliases without the "ansi" prefix, because...why?
"#black": "000000",
"#darkred": "7f0000",
"#darkgreen": "007f00",
"#brown": "7f7fe0",
"#darkblue": "00007f",
"#purple": "7f007f",
"#teal": "007f7f",
"#lightgray": "e5e5e5",
// Normal
"#darkgray": "555555",
"#red": "ff0000",
"#green": "00ff00",
"#yellow": "ffff00",
"#blue": "0000ff",
"#fuchsia": "ff00ff",
"#turquoise": "00ffff",
"#white": "ffffff",
}
// Colour represents an RGB colour.
type Colour int32
// NewColour creates a Colour directly from RGB values.
func NewColour(r, g, b uint8) Colour {
return ParseColour(fmt.Sprintf("%02x%02x%02x", r, g, b))
}
// Distance between this colour and another.
//
// This uses the approach described here (https://www.compuphase.com/cmetric.htm).
// This is not as accurate as LAB, et. al. but is *vastly* simpler and sufficient for our needs.
func (c Colour) Distance(e2 Colour) float64 {
ar, ag, ab := int64(c.Red()), int64(c.Green()), int64(c.Blue())
br, bg, bb := int64(e2.Red()), int64(e2.Green()), int64(e2.Blue())
rmean := (ar + br) / 2
r := ar - br
g := ag - bg
b := ab - bb
return math.Sqrt(float64((((512 + rmean) * r * r) >> 8) + 4*g*g + (((767 - rmean) * b * b) >> 8)))
}
// Brighten returns a copy of this colour with its brightness adjusted.
//
// If factor is negative, the colour is darkened.
//
// Uses approach described here (http://www.pvladov.com/2012/09/make-color-lighter-or-darker.html).
func (c Colour) Brighten(factor float64) Colour {
r := float64(c.Red())
g := float64(c.Green())
b := float64(c.Blue())
if factor < 0 {
factor++
r *= factor
g *= factor
b *= factor
} else {
r = (255-r)*factor + r
g = (255-g)*factor + g
b = (255-b)*factor + b
}
return NewColour(uint8(r), uint8(g), uint8(b))
}
// BrightenOrDarken brightens a colour if it is < 0.5 brightness or darkens if > 0.5 brightness.
func (c Colour) BrightenOrDarken(factor float64) Colour {
if c.Brightness() < 0.5 {
return c.Brighten(factor)
}
return c.Brighten(-factor)
}
// ClampBrightness returns a copy of this colour with its brightness adjusted such that
// it falls within the range [min, max] (or very close to it due to rounding errors).
// The supplied values use the same [0.0, 1.0] range as Brightness.
func (c Colour) ClampBrightness(min, max float64) Colour {
if !c.IsSet() {
return c
}
min = math.Max(min, 0)
max = math.Min(max, 1)
current := c.Brightness()
target := math.Min(math.Max(current, min), max)
if current == target {
return c
}
r := float64(c.Red())
g := float64(c.Green())
b := float64(c.Blue())
rgb := r + g + b
if target > current {
// Solve for x: target == ((255-r)*x + r + (255-g)*x + g + (255-b)*x + b) / 255 / 3
return c.Brighten((target*255*3 - rgb) / (255*3 - rgb))
}
// Solve for x: target == (r*(x+1) + g*(x+1) + b*(x+1)) / 255 / 3
return c.Brighten((target*255*3)/rgb - 1)
}
// Brightness of the colour (roughly) in the range 0.0 to 1.0.
func (c Colour) Brightness() float64 {
return (float64(c.Red()) + float64(c.Green()) + float64(c.Blue())) / 255.0 / 3.0
}
// ParseColour in the forms #rgb, #rrggbb, #ansi<colour>, or #<colour>.
// Will return an "unset" colour if invalid.
func ParseColour(colour string) Colour {
colour = normaliseColour(colour)
n, err := strconv.ParseUint(colour, 16, 32)
if err != nil {
return 0
}
return Colour(n + 1) //nolint:gosec
}
// MustParseColour is like ParseColour except it panics if the colour is invalid.
//
// Will panic if colour is in an invalid format.
func MustParseColour(colour string) Colour {
parsed := ParseColour(colour)
if !parsed.IsSet() {
panic(fmt.Errorf("invalid colour %q", colour))
}
return parsed
}
// IsSet returns true if the colour is set.
func (c Colour) IsSet() bool { return c != 0 }
func (c Colour) String() string { return fmt.Sprintf("#%06x", int(c-1)) }
func (c Colour) GoString() string { return fmt.Sprintf("Colour(0x%06x)", int(c-1)) }
// Red component of colour.
func (c Colour) Red() uint8 { return uint8(((c - 1) >> 16) & 0xff) } //nolint:gosec
// Green component of colour.
func (c Colour) Green() uint8 { return uint8(((c - 1) >> 8) & 0xff) } //nolint:gosec
// Blue component of colour.
func (c Colour) Blue() uint8 { return uint8((c - 1) & 0xff) } //nolint:gosec
// Colours is an orderable set of colours.
type Colours []Colour
func (c Colours) Len() int { return len(c) }
func (c Colours) Swap(i, j int) { c[i], c[j] = c[j], c[i] }
func (c Colours) Less(i, j int) bool { return c[i] < c[j] }
// Convert colours to #rrggbb.
func normaliseColour(colour string) string {
if ansi, ok := ANSI2RGB[colour]; ok {
return ansi
}
if strings.HasPrefix(colour, "#") {
colour = colour[1:]
if len(colour) == 3 {
return colour[0:1] + colour[0:1] + colour[1:2] + colour[1:2] + colour[2:3] + colour[2:3]
}
}
return colour
}

View file

@ -0,0 +1,161 @@
package chroma
import (
"bytes"
)
type delegatingLexer struct {
root Lexer
language Lexer
}
// DelegatingLexer combines two lexers to handle the common case of a language embedded inside another, such as PHP
// inside HTML or PHP inside plain text.
//
// It takes two lexer as arguments: a root lexer and a language lexer. First everything is scanned using the language
// lexer, which must return "Other" for unrecognised tokens. Then all "Other" tokens are lexed using the root lexer.
// Finally, these two sets of tokens are merged.
//
// The lexers from the template lexer package use this base lexer.
func DelegatingLexer(root Lexer, language Lexer) Lexer {
return &delegatingLexer{
root: root,
language: language,
}
}
func (d *delegatingLexer) SetTracing(enable bool) {
if l, ok := d.language.(TracingLexer); ok {
l.SetTracing(enable)
}
if l, ok := d.root.(TracingLexer); ok {
l.SetTracing(enable)
}
}
func (d *delegatingLexer) AnalyseText(text string) float32 {
return d.root.AnalyseText(text)
}
func (d *delegatingLexer) SetAnalyser(analyser func(text string) float32) Lexer {
d.root.SetAnalyser(analyser)
return d
}
func (d *delegatingLexer) SetRegistry(r *LexerRegistry) Lexer {
d.root.SetRegistry(r)
d.language.SetRegistry(r)
return d
}
func (d *delegatingLexer) Config() *Config {
return d.language.Config()
}
// An insertion is the character range where language tokens should be inserted.
type insertion struct {
start, end int
tokens []Token
}
func (d *delegatingLexer) Tokenise(options *TokeniseOptions, text string) (Iterator, error) { // nolint: gocognit
tokens, err := Tokenise(Coalesce(d.language), options, text)
if err != nil {
return nil, err
}
// Compute insertions and gather "Other" tokens.
others := &bytes.Buffer{}
insertions := []*insertion{}
var insert *insertion
offset := 0
var last Token
for _, t := range tokens {
if t.Type == Other {
if last != EOF && insert != nil && last.Type != Other {
insert.end = offset
}
others.WriteString(t.Value)
} else {
if last == EOF || last.Type == Other {
insert = &insertion{start: offset}
insertions = append(insertions, insert)
}
insert.tokens = append(insert.tokens, t)
}
last = t
offset += len(t.Value)
}
if len(insertions) == 0 {
return d.root.Tokenise(options, text)
}
// Lex the other tokens.
rootTokens, err := Tokenise(Coalesce(d.root), options, others.String())
if err != nil {
return nil, err
}
// Interleave the two sets of tokens.
var out []Token
offset = 0 // Offset into text.
tokenIndex := 0
nextToken := func() Token {
if tokenIndex >= len(rootTokens) {
return EOF
}
t := rootTokens[tokenIndex]
tokenIndex++
return t
}
insertionIndex := 0
nextInsertion := func() *insertion {
if insertionIndex >= len(insertions) {
return nil
}
i := insertions[insertionIndex]
insertionIndex++
return i
}
t := nextToken()
i := nextInsertion()
for t != EOF || i != nil {
// fmt.Printf("%d->%d:%q %d->%d:%q\n", offset, offset+len(t.Value), t.Value, i.start, i.end, Stringify(i.tokens...))
if t == EOF || (i != nil && i.start < offset+len(t.Value)) {
var l Token
l, t = splitToken(t, i.start-offset)
if l != EOF {
out = append(out, l)
offset += len(l.Value)
}
out = append(out, i.tokens...)
offset += i.end - i.start
if t == EOF {
t = nextToken()
}
i = nextInsertion()
} else {
out = append(out, t)
offset += len(t.Value)
t = nextToken()
}
}
return Literator(out...), nil
}
func splitToken(t Token, offset int) (l Token, r Token) {
if t == EOF {
return EOF, EOF
}
if offset == 0 {
return EOF, t
}
if offset == len(t.Value) {
return t, EOF
}
l = t.Clone()
r = t.Clone()
l.Value = l.Value[:offset]
r.Value = r.Value[offset:]
return
}

View file

@ -0,0 +1,7 @@
// Package chroma takes source code and other structured text and converts it into syntax highlighted HTML, ANSI-
// coloured text, etc.
//
// Chroma is based heavily on Pygments, and includes translators for Pygments lexers and styles.
//
// For more information, go here: https://github.com/alecthomas/chroma
package chroma

View file

@ -0,0 +1,233 @@
package chroma
import (
"fmt"
)
// An Emitter takes group matches and returns tokens.
type Emitter interface {
// Emit tokens for the given regex groups.
Emit(groups []string, state *LexerState) Iterator
}
// ValidatingEmitter is an Emitter that can validate against a compiled rule.
type ValidatingEmitter interface {
Emitter
ValidateEmitter(rule *CompiledRule) error
}
// SerialisableEmitter is an Emitter that can be serialised and deserialised to/from JSON.
type SerialisableEmitter interface {
Emitter
EmitterKind() string
}
// EmitterFunc is a function that is an Emitter.
type EmitterFunc func(groups []string, state *LexerState) Iterator
// Emit tokens for groups.
func (e EmitterFunc) Emit(groups []string, state *LexerState) Iterator {
return e(groups, state)
}
type Emitters []Emitter
type byGroupsEmitter struct {
Emitters
}
var _ ValidatingEmitter = (*byGroupsEmitter)(nil)
// ByGroups emits a token for each matching group in the rule's regex.
func ByGroups(emitters ...Emitter) Emitter {
return &byGroupsEmitter{Emitters: emitters}
}
func (b *byGroupsEmitter) EmitterKind() string { return "bygroups" }
func (b *byGroupsEmitter) ValidateEmitter(rule *CompiledRule) error {
if len(rule.Regexp.GetGroupNumbers())-1 != len(b.Emitters) {
return fmt.Errorf("number of groups %d does not match number of emitters %d", len(rule.Regexp.GetGroupNumbers())-1, len(b.Emitters))
}
return nil
}
func (b *byGroupsEmitter) Emit(groups []string, state *LexerState) Iterator {
iterators := make([]Iterator, 0, len(groups)-1)
if len(b.Emitters) != len(groups)-1 {
iterators = append(iterators, Error.Emit(groups, state))
// panic(errors.Errorf("number of groups %q does not match number of emitters %v", groups, emitters))
} else {
for i, group := range groups[1:] {
if b.Emitters[i] != nil {
iterators = append(iterators, b.Emitters[i].Emit([]string{group}, state))
}
}
}
return Concaterator(iterators...)
}
// ByGroupNames emits a token for each named matching group in the rule's regex.
func ByGroupNames(emitters map[string]Emitter) Emitter {
return EmitterFunc(func(groups []string, state *LexerState) Iterator {
iterators := make([]Iterator, 0, len(state.NamedGroups)-1)
if len(state.NamedGroups)-1 == 0 {
if emitter, ok := emitters[`0`]; ok {
iterators = append(iterators, emitter.Emit(groups, state))
} else {
iterators = append(iterators, Error.Emit(groups, state))
}
} else {
ruleRegex := state.Rules[state.State][state.Rule].Regexp
for i := 1; i < len(state.NamedGroups); i++ {
groupName := ruleRegex.GroupNameFromNumber(i)
group := state.NamedGroups[groupName]
if emitter, ok := emitters[groupName]; ok {
if emitter != nil {
iterators = append(iterators, emitter.Emit([]string{group}, state))
}
} else {
iterators = append(iterators, Error.Emit([]string{group}, state))
}
}
}
return Concaterator(iterators...)
})
}
// UsingByGroup emits tokens for the matched groups in the regex using a
// sublexer. Used when lexing code blocks where the name of a sublexer is
// contained within the block, for example on a Markdown text block or SQL
// language block.
//
// An attempt to load the sublexer will be made using the captured value from
// the text of the matched sublexerNameGroup. If a sublexer matching the
// sublexerNameGroup is available, then tokens for the matched codeGroup will
// be emitted using the sublexer. Otherwise, if no sublexer is available, then
// tokens will be emitted from the passed emitter.
//
// Example:
//
// var Markdown = internal.Register(MustNewLexer(
// &Config{
// Name: "markdown",
// Aliases: []string{"md", "mkd"},
// Filenames: []string{"*.md", "*.mkd", "*.markdown"},
// MimeTypes: []string{"text/x-markdown"},
// },
// Rules{
// "root": {
// {"^(```)(\\w+)(\\n)([\\w\\W]*?)(^```$)",
// UsingByGroup(
// 2, 4,
// String, String, String, Text, String,
// ),
// nil,
// },
// },
// },
// ))
//
// See the lexers/markdown.go for the complete example.
//
// Note: panic's if the number of emitters does not equal the number of matched
// groups in the regex.
func UsingByGroup(sublexerNameGroup, codeGroup int, emitters ...Emitter) Emitter {
return &usingByGroup{
SublexerNameGroup: sublexerNameGroup,
CodeGroup: codeGroup,
Emitters: emitters,
}
}
type usingByGroup struct {
SublexerNameGroup int `xml:"sublexer_name_group"`
CodeGroup int `xml:"code_group"`
Emitters Emitters `xml:"emitters"`
}
func (u *usingByGroup) EmitterKind() string { return "usingbygroup" }
func (u *usingByGroup) Emit(groups []string, state *LexerState) Iterator {
// bounds check
if len(u.Emitters) != len(groups)-1 {
panic("UsingByGroup expects number of emitters to be the same as len(groups)-1")
}
// grab sublexer
sublexer := state.Registry.Get(groups[u.SublexerNameGroup])
// build iterators
iterators := make([]Iterator, len(groups)-1)
for i, group := range groups[1:] {
if i == u.CodeGroup-1 && sublexer != nil {
var err error
iterators[i], err = sublexer.Tokenise(nil, groups[u.CodeGroup])
if err != nil {
panic(err)
}
} else if u.Emitters[i] != nil {
iterators[i] = u.Emitters[i].Emit([]string{group}, state)
}
}
return Concaterator(iterators...)
}
// UsingLexer returns an Emitter that uses a given Lexer for parsing and emitting.
//
// This Emitter is not serialisable.
func UsingLexer(lexer Lexer) Emitter {
return EmitterFunc(func(groups []string, _ *LexerState) Iterator {
it, err := lexer.Tokenise(&TokeniseOptions{State: "root", Nested: true}, groups[0])
if err != nil {
panic(err)
}
return it
})
}
type usingEmitter struct {
Lexer string `xml:"lexer,attr"`
}
func (u *usingEmitter) EmitterKind() string { return "using" }
func (u *usingEmitter) Emit(groups []string, state *LexerState) Iterator {
if state.Registry == nil {
panic(fmt.Sprintf("no LexerRegistry available for Using(%q)", u.Lexer))
}
lexer := state.Registry.Get(u.Lexer)
if lexer == nil {
panic(fmt.Sprintf("no such lexer %q", u.Lexer))
}
it, err := lexer.Tokenise(&TokeniseOptions{State: "root", Nested: true}, groups[0])
if err != nil {
panic(err)
}
return it
}
// Using returns an Emitter that uses a given Lexer reference for parsing and emitting.
//
// The referenced lexer must be stored in the same LexerRegistry.
func Using(lexer string) Emitter {
return &usingEmitter{Lexer: lexer}
}
type usingSelfEmitter struct {
State string `xml:"state,attr"`
}
func (u *usingSelfEmitter) EmitterKind() string { return "usingself" }
func (u *usingSelfEmitter) Emit(groups []string, state *LexerState) Iterator {
it, err := state.Lexer.Tokenise(&TokeniseOptions{State: u.State, Nested: true}, groups[0])
if err != nil {
panic(err)
}
return it
}
// UsingSelf is like Using, but uses the current Lexer.
func UsingSelf(stateName string) Emitter {
return &usingSelfEmitter{stateName}
}

View file

@ -0,0 +1,43 @@
package chroma
import (
"io"
)
// A Formatter for Chroma lexers.
type Formatter interface {
// Format returns a formatting function for tokens.
//
// If the iterator panics, the Formatter should recover.
Format(w io.Writer, style *Style, iterator Iterator) error
}
// A FormatterFunc is a Formatter implemented as a function.
//
// Guards against iterator panics.
type FormatterFunc func(w io.Writer, style *Style, iterator Iterator) error
func (f FormatterFunc) Format(w io.Writer, s *Style, it Iterator) (err error) { // nolint
defer func() {
if perr := recover(); perr != nil {
err = perr.(error)
}
}()
return f(w, s, it)
}
type recoveringFormatter struct {
Formatter
}
func (r recoveringFormatter) Format(w io.Writer, s *Style, it Iterator) (err error) {
defer func() {
if perr := recover(); perr != nil {
err = perr.(error)
}
}()
return r.Formatter.Format(w, s, it)
}
// RecoveringFormatter wraps a formatter with panic recovery.
func RecoveringFormatter(formatter Formatter) Formatter { return recoveringFormatter{formatter} }

View file

@ -0,0 +1,57 @@
package formatters
import (
"io"
"sort"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/formatters/svg"
)
var (
// NoOp formatter.
NoOp = Register("noop", chroma.FormatterFunc(func(w io.Writer, s *chroma.Style, iterator chroma.Iterator) error {
for t := iterator(); t != chroma.EOF; t = iterator() {
if _, err := io.WriteString(w, t.Value); err != nil {
return err
}
}
return nil
}))
// Default HTML formatter outputs self-contained HTML.
htmlFull = Register("html", html.New(html.Standalone(true), html.WithClasses(true))) // nolint
SVG = Register("svg", svg.New(svg.EmbedFont("Liberation Mono", svg.FontLiberationMono, svg.WOFF)))
)
// Fallback formatter.
var Fallback = NoOp
// Registry of Formatters.
var Registry = map[string]chroma.Formatter{}
// Names of registered formatters.
func Names() []string {
out := []string{}
for name := range Registry {
out = append(out, name)
}
sort.Strings(out)
return out
}
// Get formatter by name.
//
// If the given formatter is not found, the Fallback formatter will be returned.
func Get(name string) chroma.Formatter {
if f, ok := Registry[name]; ok {
return f
}
return Fallback
}
// Register a named formatter.
func Register(name string, formatter chroma.Formatter) chroma.Formatter {
Registry[name] = formatter
return formatter
}

View file

@ -0,0 +1,648 @@
package html
import (
"fmt"
"html"
"io"
"sort"
"strconv"
"strings"
"sync"
"github.com/alecthomas/chroma/v2"
)
// Option sets an option of the HTML formatter.
type Option func(f *Formatter)
// Standalone configures the HTML formatter for generating a standalone HTML document.
func Standalone(b bool) Option { return func(f *Formatter) { f.standalone = b } }
// ClassPrefix sets the CSS class prefix.
func ClassPrefix(prefix string) Option { return func(f *Formatter) { f.prefix = prefix } }
// WithClasses emits HTML using CSS classes, rather than inline styles.
func WithClasses(b bool) Option { return func(f *Formatter) { f.Classes = b } }
// WithAllClasses disables an optimisation that omits redundant CSS classes.
func WithAllClasses(b bool) Option { return func(f *Formatter) { f.allClasses = b } }
// WithCustomCSS sets user's custom CSS styles.
func WithCustomCSS(css map[chroma.TokenType]string) Option {
return func(f *Formatter) {
f.customCSS = css
}
}
// WithCSSComments adds prefixe comments to the css classes. Defaults to true.
func WithCSSComments(b bool) Option { return func(f *Formatter) { f.writeCSSComments = b } }
// TabWidth sets the number of characters for a tab. Defaults to 8.
func TabWidth(width int) Option { return func(f *Formatter) { f.tabWidth = width } }
// PreventSurroundingPre prevents the surrounding pre tags around the generated code.
func PreventSurroundingPre(b bool) Option {
return func(f *Formatter) {
f.preventSurroundingPre = b
if b {
f.preWrapper = nopPreWrapper
} else {
f.preWrapper = defaultPreWrapper
}
}
}
// InlineCode creates inline code wrapped in a code tag.
func InlineCode(b bool) Option {
return func(f *Formatter) {
f.inlineCode = b
f.preWrapper = preWrapper{
start: func(code bool, styleAttr string) string {
if code {
return fmt.Sprintf(`<code%s>`, styleAttr)
}
return ``
},
end: func(code bool) string {
if code {
return `</code>`
}
return ``
},
}
}
}
// WithPreWrapper allows control of the surrounding pre tags.
func WithPreWrapper(wrapper PreWrapper) Option {
return func(f *Formatter) {
f.preWrapper = wrapper
}
}
// WrapLongLines wraps long lines.
func WrapLongLines(b bool) Option {
return func(f *Formatter) {
f.wrapLongLines = b
}
}
// WithLineNumbers formats output with line numbers.
func WithLineNumbers(b bool) Option {
return func(f *Formatter) {
f.lineNumbers = b
}
}
// LineNumbersInTable will, when combined with WithLineNumbers, separate the line numbers
// and code in table td's, which make them copy-and-paste friendly.
func LineNumbersInTable(b bool) Option {
return func(f *Formatter) {
f.lineNumbersInTable = b
}
}
// WithLinkableLineNumbers decorates the line numbers HTML elements with an "id"
// attribute so they can be linked.
func WithLinkableLineNumbers(b bool, prefix string) Option {
return func(f *Formatter) {
f.linkableLineNumbers = b
f.lineNumbersIDPrefix = prefix
}
}
// HighlightLines higlights the given line ranges with the Highlight style.
//
// A range is the beginning and ending of a range as 1-based line numbers, inclusive.
func HighlightLines(ranges [][2]int) Option {
return func(f *Formatter) {
f.highlightRanges = ranges
sort.Sort(f.highlightRanges)
}
}
// BaseLineNumber sets the initial number to start line numbering at. Defaults to 1.
func BaseLineNumber(n int) Option {
return func(f *Formatter) {
f.baseLineNumber = n
}
}
// New HTML formatter.
func New(options ...Option) *Formatter {
f := &Formatter{
baseLineNumber: 1,
preWrapper: defaultPreWrapper,
writeCSSComments: true,
}
f.styleCache = newStyleCache(f)
for _, option := range options {
option(f)
}
return f
}
// PreWrapper defines the operations supported in WithPreWrapper.
type PreWrapper interface {
// Start is called to write a start <pre> element.
// The code flag tells whether this block surrounds
// highlighted code. This will be false when surrounding
// line numbers.
Start(code bool, styleAttr string) string
// End is called to write the end </pre> element.
End(code bool) string
}
type preWrapper struct {
start func(code bool, styleAttr string) string
end func(code bool) string
}
func (p preWrapper) Start(code bool, styleAttr string) string {
return p.start(code, styleAttr)
}
func (p preWrapper) End(code bool) string {
return p.end(code)
}
var (
nopPreWrapper = preWrapper{
start: func(code bool, styleAttr string) string { return "" },
end: func(code bool) string { return "" },
}
defaultPreWrapper = preWrapper{
start: func(code bool, styleAttr string) string {
if code {
return fmt.Sprintf(`<pre%s><code>`, styleAttr)
}
return fmt.Sprintf(`<pre%s>`, styleAttr)
},
end: func(code bool) string {
if code {
return `</code></pre>`
}
return `</pre>`
},
}
)
// Formatter that generates HTML.
type Formatter struct {
styleCache *styleCache
standalone bool
prefix string
Classes bool // Exported field to detect when classes are being used
allClasses bool
customCSS map[chroma.TokenType]string
writeCSSComments bool
preWrapper PreWrapper
inlineCode bool
preventSurroundingPre bool
tabWidth int
wrapLongLines bool
lineNumbers bool
lineNumbersInTable bool
linkableLineNumbers bool
lineNumbersIDPrefix string
highlightRanges highlightRanges
baseLineNumber int
}
type highlightRanges [][2]int
func (h highlightRanges) Len() int { return len(h) }
func (h highlightRanges) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h highlightRanges) Less(i, j int) bool { return h[i][0] < h[j][0] }
func (f *Formatter) Format(w io.Writer, style *chroma.Style, iterator chroma.Iterator) (err error) {
return f.writeHTML(w, style, iterator.Tokens())
}
// We deliberately don't use html/template here because it is two orders of magnitude slower (benchmarked).
//
// OTOH we need to be super careful about correct escaping...
func (f *Formatter) writeHTML(w io.Writer, style *chroma.Style, tokens []chroma.Token) (err error) { // nolint: gocyclo
css := f.styleCache.get(style, true)
if f.standalone {
fmt.Fprint(w, "<html>\n")
if f.Classes {
fmt.Fprint(w, "<style type=\"text/css\">\n")
err = f.WriteCSS(w, style)
if err != nil {
return err
}
fmt.Fprintf(w, "body { %s; }\n", css[chroma.Background])
fmt.Fprint(w, "</style>")
}
fmt.Fprintf(w, "<body%s>\n", f.styleAttr(css, chroma.Background))
}
wrapInTable := f.lineNumbers && f.lineNumbersInTable
lines := chroma.SplitTokensIntoLines(tokens)
lineDigits := len(strconv.Itoa(f.baseLineNumber + len(lines) - 1))
highlightIndex := 0
if wrapInTable {
// List line numbers in its own <td>
fmt.Fprintf(w, "<div%s>\n", f.styleAttr(css, chroma.PreWrapper))
fmt.Fprintf(w, "<table%s><tr>", f.styleAttr(css, chroma.LineTable))
fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD))
fmt.Fprintf(w, "%s", f.preWrapper.Start(false, f.styleAttr(css, chroma.PreWrapper)))
for index := range lines {
line := f.baseLineNumber + index
highlight, next := f.shouldHighlight(highlightIndex, line)
if next {
highlightIndex++
}
if highlight {
fmt.Fprintf(w, "<span%s>", f.styleAttr(css, chroma.LineHighlight))
}
fmt.Fprintf(w, "<span%s%s>%s\n</span>", f.styleAttr(css, chroma.LineNumbersTable), f.lineIDAttribute(line), f.lineTitleWithLinkIfNeeded(css, lineDigits, line))
if highlight {
fmt.Fprintf(w, "</span>")
}
}
fmt.Fprint(w, f.preWrapper.End(false))
fmt.Fprint(w, "</td>\n")
fmt.Fprintf(w, "<td%s>\n", f.styleAttr(css, chroma.LineTableTD, "width:100%"))
}
fmt.Fprintf(w, "%s", f.preWrapper.Start(true, f.styleAttr(css, chroma.PreWrapper)))
highlightIndex = 0
for index, tokens := range lines {
// 1-based line number.
line := f.baseLineNumber + index
highlight, next := f.shouldHighlight(highlightIndex, line)
if next {
highlightIndex++
}
if !(f.preventSurroundingPre || f.inlineCode) {
// Start of Line
fmt.Fprint(w, `<span`)
if highlight {
// Line + LineHighlight
if f.Classes {
fmt.Fprintf(w, ` class="%s %s"`, f.class(chroma.Line), f.class(chroma.LineHighlight))
} else {
fmt.Fprintf(w, ` style="%s %s"`, css[chroma.Line], css[chroma.LineHighlight])
}
fmt.Fprint(w, `>`)
} else {
fmt.Fprintf(w, "%s>", f.styleAttr(css, chroma.Line))
}
// Line number
if f.lineNumbers && !wrapInTable {
fmt.Fprintf(w, "<span%s%s>%s</span>", f.styleAttr(css, chroma.LineNumbers), f.lineIDAttribute(line), f.lineTitleWithLinkIfNeeded(css, lineDigits, line))
}
fmt.Fprintf(w, `<span%s>`, f.styleAttr(css, chroma.CodeLine))
}
for _, token := range tokens {
html := html.EscapeString(token.String())
attr := f.styleAttr(css, token.Type)
if attr != "" {
html = fmt.Sprintf("<span%s>%s</span>", attr, html)
}
fmt.Fprint(w, html)
}
if !(f.preventSurroundingPre || f.inlineCode) {
fmt.Fprint(w, `</span>`) // End of CodeLine
fmt.Fprint(w, `</span>`) // End of Line
}
}
fmt.Fprintf(w, "%s", f.preWrapper.End(true))
if wrapInTable {
fmt.Fprint(w, "</td></tr></table>\n")
fmt.Fprint(w, "</div>\n")
}
if f.standalone {
fmt.Fprint(w, "\n</body>\n")
fmt.Fprint(w, "</html>\n")
}
return nil
}
func (f *Formatter) lineIDAttribute(line int) string {
if !f.linkableLineNumbers {
return ""
}
return fmt.Sprintf(" id=\"%s\"", f.lineID(line))
}
func (f *Formatter) lineTitleWithLinkIfNeeded(css map[chroma.TokenType]string, lineDigits, line int) string {
title := fmt.Sprintf("%*d", lineDigits, line)
if !f.linkableLineNumbers {
return title
}
return fmt.Sprintf("<a%s href=\"#%s\">%s</a>", f.styleAttr(css, chroma.LineLink), f.lineID(line), title)
}
func (f *Formatter) lineID(line int) string {
return fmt.Sprintf("%s%d", f.lineNumbersIDPrefix, line)
}
func (f *Formatter) shouldHighlight(highlightIndex, line int) (bool, bool) {
next := false
for highlightIndex < len(f.highlightRanges) && line > f.highlightRanges[highlightIndex][1] {
highlightIndex++
next = true
}
if highlightIndex < len(f.highlightRanges) {
hrange := f.highlightRanges[highlightIndex]
if line >= hrange[0] && line <= hrange[1] {
return true, next
}
}
return false, next
}
func (f *Formatter) class(t chroma.TokenType) string {
for t != 0 {
if cls, ok := chroma.StandardTypes[t]; ok {
if cls != "" {
return f.prefix + cls
}
return ""
}
t = t.Parent()
}
if cls := chroma.StandardTypes[t]; cls != "" {
return f.prefix + cls
}
return ""
}
func (f *Formatter) styleAttr(styles map[chroma.TokenType]string, tt chroma.TokenType, extraCSS ...string) string {
if f.Classes {
cls := f.class(tt)
if cls == "" {
return ""
}
return fmt.Sprintf(` class="%s"`, cls)
}
if _, ok := styles[tt]; !ok {
tt = tt.SubCategory()
if _, ok := styles[tt]; !ok {
tt = tt.Category()
if _, ok := styles[tt]; !ok {
return ""
}
}
}
css := []string{styles[tt]}
css = append(css, extraCSS...)
return fmt.Sprintf(` style="%s"`, strings.Join(css, ";"))
}
func (f *Formatter) tabWidthStyle() string {
if f.tabWidth != 0 && f.tabWidth != 8 {
return fmt.Sprintf("-moz-tab-size: %[1]d; -o-tab-size: %[1]d; tab-size: %[1]d;", f.tabWidth)
}
return ""
}
func (f *Formatter) writeCSSRule(w io.Writer, comment string, selector string, styles string) error {
if styles == "" {
return nil
}
if f.writeCSSComments && comment != "" {
if _, err := fmt.Fprintf(w, "/* %s */ ", comment); err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, "%s { %s }\n", selector, styles); err != nil {
return err
}
return nil
}
// WriteCSS writes CSS style definitions (without any surrounding HTML).
func (f *Formatter) WriteCSS(w io.Writer, style *chroma.Style) error {
css := f.styleCache.get(style, false)
// Special-case background as it is mapped to the outer ".chroma" class.
if err := f.writeCSSRule(w, chroma.Background.String(), fmt.Sprintf(".%sbg", f.prefix), css[chroma.Background]); err != nil {
return err
}
// Special-case PreWrapper as it is the ".chroma" class.
if err := f.writeCSSRule(w, chroma.PreWrapper.String(), fmt.Sprintf(".%schroma", f.prefix), css[chroma.PreWrapper]); err != nil {
return err
}
// Special-case code column of table to expand width.
if f.lineNumbers && f.lineNumbersInTable {
selector := fmt.Sprintf(".%schroma .%s:last-child", f.prefix, f.class(chroma.LineTableTD))
if err := f.writeCSSRule(w, chroma.LineTableTD.String(), selector, "width: 100%;"); err != nil {
return err
}
}
// Special-case line number highlighting when targeted.
if f.lineNumbers || f.lineNumbersInTable {
targetedLineCSS := StyleEntryToCSS(style.Get(chroma.LineHighlight))
for _, tt := range []chroma.TokenType{chroma.LineNumbers, chroma.LineNumbersTable} {
comment := fmt.Sprintf("%s targeted by URL anchor", tt)
selector := fmt.Sprintf(".%schroma .%s:target", f.prefix, f.class(tt))
if err := f.writeCSSRule(w, comment, selector, targetedLineCSS); err != nil {
return err
}
}
}
tts := []int{}
for tt := range css {
tts = append(tts, int(tt))
}
sort.Ints(tts)
for _, ti := range tts {
tt := chroma.TokenType(ti)
switch tt {
case chroma.Background, chroma.PreWrapper:
continue
}
class := f.class(tt)
if class == "" {
continue
}
if err := f.writeCSSRule(w, tt.String(), fmt.Sprintf(".%schroma .%s", f.prefix, class), css[tt]); err != nil {
return err
}
}
return nil
}
func (f *Formatter) styleToCSS(style *chroma.Style) map[chroma.TokenType]string {
classes := map[chroma.TokenType]string{}
bg := style.Get(chroma.Background)
// Convert the style.
for t := range chroma.StandardTypes {
entry := style.Get(t)
if t != chroma.Background {
entry = entry.Sub(bg)
}
// Inherit from custom CSS provided by user
tokenCategory := t.Category()
tokenSubCategory := t.SubCategory()
if t != tokenCategory {
if css, ok := f.customCSS[tokenCategory]; ok {
classes[t] = css
}
}
if tokenCategory != tokenSubCategory {
if css, ok := f.customCSS[tokenSubCategory]; ok {
classes[t] += css
}
}
// Add custom CSS provided by user
if css, ok := f.customCSS[t]; ok {
classes[t] += css
}
if !f.allClasses && entry.IsZero() && classes[t] == `` {
continue
}
styleEntryCSS := StyleEntryToCSS(entry)
if styleEntryCSS != `` && classes[t] != `` {
styleEntryCSS += `;`
}
classes[t] = styleEntryCSS + classes[t]
}
classes[chroma.Background] += `;` + f.tabWidthStyle()
classes[chroma.PreWrapper] += classes[chroma.Background]
classes[chroma.PreWrapper] += ` -webkit-text-size-adjust: none;`
// Make PreWrapper a grid to show highlight style with full width.
if len(f.highlightRanges) > 0 && f.customCSS[chroma.PreWrapper] == `` {
classes[chroma.PreWrapper] += `display: grid;`
}
// Make PreWrapper wrap long lines.
if f.wrapLongLines {
classes[chroma.PreWrapper] += `white-space: pre-wrap; word-break: break-word;`
}
lineNumbersStyle := `white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;`
// All rules begin with default rules followed by user provided rules
classes[chroma.Line] = `display: flex;` + classes[chroma.Line]
classes[chroma.LineNumbers] = lineNumbersStyle + classes[chroma.LineNumbers]
classes[chroma.LineNumbersTable] = lineNumbersStyle + classes[chroma.LineNumbersTable]
classes[chroma.LineTable] = "border-spacing: 0; padding: 0; margin: 0; border: 0;" + classes[chroma.LineTable]
classes[chroma.LineTableTD] = "vertical-align: top; padding: 0; margin: 0; border: 0;" + classes[chroma.LineTableTD]
classes[chroma.LineLink] = "outline: none; text-decoration: none; color: inherit" + classes[chroma.LineLink]
return classes
}
// StyleEntryToCSS converts a chroma.StyleEntry to CSS attributes.
func StyleEntryToCSS(e chroma.StyleEntry) string {
styles := []string{}
if e.Colour.IsSet() {
styles = append(styles, "color: "+e.Colour.String())
}
if e.Background.IsSet() {
styles = append(styles, "background-color: "+e.Background.String())
}
if e.Bold == chroma.Yes {
styles = append(styles, "font-weight: bold")
}
if e.Italic == chroma.Yes {
styles = append(styles, "font-style: italic")
}
if e.Underline == chroma.Yes {
styles = append(styles, "text-decoration: underline")
}
return strings.Join(styles, "; ")
}
// Compress CSS attributes - remove spaces, transform 6-digit colours to 3.
func compressStyle(s string) string {
parts := strings.Split(s, ";")
out := []string{}
for _, p := range parts {
p = strings.Join(strings.Fields(p), " ")
p = strings.Replace(p, ": ", ":", 1)
if strings.Contains(p, "#") {
c := p[len(p)-6:]
if c[0] == c[1] && c[2] == c[3] && c[4] == c[5] {
p = p[:len(p)-6] + c[0:1] + c[2:3] + c[4:5]
}
}
out = append(out, p)
}
return strings.Join(out, ";")
}
const styleCacheLimit = 32
type styleCacheEntry struct {
style *chroma.Style
compressed bool
cache map[chroma.TokenType]string
}
type styleCache struct {
mu sync.Mutex
// LRU cache of compiled (and possibly compressed) styles. This is a slice
// because the cache size is small, and a slice is sufficiently fast for
// small N.
cache []styleCacheEntry
f *Formatter
}
func newStyleCache(f *Formatter) *styleCache {
return &styleCache{f: f}
}
func (l *styleCache) get(style *chroma.Style, compress bool) map[chroma.TokenType]string {
l.mu.Lock()
defer l.mu.Unlock()
// Look for an existing entry.
for i := len(l.cache) - 1; i >= 0; i-- {
entry := l.cache[i]
if entry.style == style && entry.compressed == compress {
// Top of the cache, no need to adjust the order.
if i == len(l.cache)-1 {
return entry.cache
}
// Move this entry to the end of the LRU
copy(l.cache[i:], l.cache[i+1:])
l.cache[len(l.cache)-1] = entry
return entry.cache
}
}
// No entry, create one.
cached := l.f.styleToCSS(style)
if !l.f.Classes {
for t, style := range cached {
cached[t] = compressStyle(style)
}
}
if compress {
for t, style := range cached {
cached[t] = compressStyle(style)
}
}
// Evict the oldest entry.
if len(l.cache) >= styleCacheLimit {
l.cache = l.cache[0:copy(l.cache, l.cache[1:])]
}
l.cache = append(l.cache, styleCacheEntry{style: style, cache: cached, compressed: compress})
return cached
}

View file

@ -0,0 +1,39 @@
package formatters
import (
"encoding/json"
"fmt"
"io"
"github.com/alecthomas/chroma/v2"
)
// JSON formatter outputs the raw token structures as JSON.
var JSON = Register("json", chroma.FormatterFunc(func(w io.Writer, s *chroma.Style, it chroma.Iterator) error {
if _, err := fmt.Fprintln(w, "["); err != nil {
return err
}
i := 0
for t := it(); t != chroma.EOF; t = it() {
if i > 0 {
if _, err := fmt.Fprintln(w, ","); err != nil {
return err
}
}
i++
bytes, err := json.Marshal(t)
if err != nil {
return err
}
if _, err := fmt.Fprint(w, " "+string(bytes)); err != nil {
return err
}
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
if _, err := fmt.Fprintln(w, "]"); err != nil {
return err
}
return nil
}))

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,222 @@
// Package svg contains an SVG formatter.
package svg
import (
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"path"
"strings"
"github.com/alecthomas/chroma/v2"
)
// Option sets an option of the SVG formatter.
type Option func(f *Formatter)
// FontFamily sets the font-family.
func FontFamily(fontFamily string) Option { return func(f *Formatter) { f.fontFamily = fontFamily } }
// EmbedFontFile embeds given font file
func EmbedFontFile(fontFamily string, fileName string) (option Option, err error) {
var format FontFormat
switch path.Ext(fileName) {
case ".woff":
format = WOFF
case ".woff2":
format = WOFF2
case ".ttf":
format = TRUETYPE
default:
return nil, errors.New("unexpected font file suffix")
}
var content []byte
if content, err = os.ReadFile(fileName); err == nil {
option = EmbedFont(fontFamily, base64.StdEncoding.EncodeToString(content), format)
}
return
}
// EmbedFont embeds given base64 encoded font
func EmbedFont(fontFamily string, font string, format FontFormat) Option {
return func(f *Formatter) { f.fontFamily = fontFamily; f.embeddedFont = font; f.fontFormat = format }
}
// New SVG formatter.
func New(options ...Option) *Formatter {
f := &Formatter{fontFamily: "Consolas, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace"}
for _, option := range options {
option(f)
}
return f
}
// Formatter that generates SVG.
type Formatter struct {
fontFamily string
embeddedFont string
fontFormat FontFormat
}
func (f *Formatter) Format(w io.Writer, style *chroma.Style, iterator chroma.Iterator) (err error) {
f.writeSVG(w, style, iterator.Tokens())
return err
}
var svgEscaper = strings.NewReplacer(
`&`, "&amp;",
`<`, "&lt;",
`>`, "&gt;",
`"`, "&quot;",
` `, "&#160;",
` `, "&#160;&#160;&#160;&#160;",
)
// EscapeString escapes special characters.
func escapeString(s string) string {
return svgEscaper.Replace(s)
}
func (f *Formatter) writeSVG(w io.Writer, style *chroma.Style, tokens []chroma.Token) { // nolint: gocyclo
svgStyles := f.styleToSVG(style)
lines := chroma.SplitTokensIntoLines(tokens)
fmt.Fprint(w, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
fmt.Fprint(w, "<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.0//EN\" \"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd\">\n")
fmt.Fprintf(w, "<svg width=\"%dpx\" height=\"%dpx\" xmlns=\"http://www.w3.org/2000/svg\">\n", 8*maxLineWidth(lines), 10+int(16.8*float64(len(lines)+1)))
if f.embeddedFont != "" {
f.writeFontStyle(w)
}
fmt.Fprintf(w, "<rect width=\"100%%\" height=\"100%%\" fill=\"%s\"/>\n", style.Get(chroma.Background).Background.String())
fmt.Fprintf(w, "<g font-family=\"%s\" font-size=\"14px\" fill=\"%s\">\n", f.fontFamily, style.Get(chroma.Text).Colour.String())
f.writeTokenBackgrounds(w, lines, style)
for index, tokens := range lines {
fmt.Fprintf(w, "<text x=\"0\" y=\"%fem\" xml:space=\"preserve\">", 1.2*float64(index+1))
for _, token := range tokens {
text := escapeString(token.String())
attr := f.styleAttr(svgStyles, token.Type)
if attr != "" {
text = fmt.Sprintf("<tspan %s>%s</tspan>", attr, text)
}
fmt.Fprint(w, text)
}
fmt.Fprint(w, "</text>")
}
fmt.Fprint(w, "\n</g>\n")
fmt.Fprint(w, "</svg>\n")
}
func maxLineWidth(lines [][]chroma.Token) int {
maxWidth := 0
for _, tokens := range lines {
length := 0
for _, token := range tokens {
length += len(strings.ReplaceAll(token.String(), ` `, " "))
}
if length > maxWidth {
maxWidth = length
}
}
return maxWidth
}
// There is no background attribute for text in SVG so simply calculate the position and text
// of tokens with a background color that differs from the default and add a rectangle for each before
// adding the token.
func (f *Formatter) writeTokenBackgrounds(w io.Writer, lines [][]chroma.Token, style *chroma.Style) {
for index, tokens := range lines {
lineLength := 0
for _, token := range tokens {
length := len(strings.ReplaceAll(token.String(), ` `, " "))
tokenBackground := style.Get(token.Type).Background
if tokenBackground.IsSet() && tokenBackground != style.Get(chroma.Background).Background {
fmt.Fprintf(w, "<rect id=\"%s\" x=\"%dch\" y=\"%fem\" width=\"%dch\" height=\"1.2em\" fill=\"%s\" />\n", escapeString(token.String()), lineLength, 1.2*float64(index)+0.25, length, style.Get(token.Type).Background.String())
}
lineLength += length
}
}
}
type FontFormat int
// https://transfonter.org/formats
const (
WOFF FontFormat = iota
WOFF2
TRUETYPE
)
var fontFormats = [...]string{
"woff",
"woff2",
"truetype",
}
func (f *Formatter) writeFontStyle(w io.Writer) {
fmt.Fprintf(w, `<style>
@font-face {
font-family: '%s';
src: url(data:application/x-font-%s;charset=utf-8;base64,%s) format('%s');'
font-weight: normal;
font-style: normal;
}
</style>`, f.fontFamily, fontFormats[f.fontFormat], f.embeddedFont, fontFormats[f.fontFormat])
}
func (f *Formatter) styleAttr(styles map[chroma.TokenType]string, tt chroma.TokenType) string {
if _, ok := styles[tt]; !ok {
tt = tt.SubCategory()
if _, ok := styles[tt]; !ok {
tt = tt.Category()
if _, ok := styles[tt]; !ok {
return ""
}
}
}
return styles[tt]
}
func (f *Formatter) styleToSVG(style *chroma.Style) map[chroma.TokenType]string {
converted := map[chroma.TokenType]string{}
bg := style.Get(chroma.Background)
// Convert the style.
for t := range chroma.StandardTypes {
entry := style.Get(t)
if t != chroma.Background {
entry = entry.Sub(bg)
}
if entry.IsZero() {
continue
}
converted[t] = StyleEntryToSVG(entry)
}
return converted
}
// StyleEntryToSVG converts a chroma.StyleEntry to SVG attributes.
func StyleEntryToSVG(e chroma.StyleEntry) string {
var styles []string
if e.Colour.IsSet() {
styles = append(styles, "fill=\""+e.Colour.String()+"\"")
}
if e.Bold == chroma.Yes {
styles = append(styles, "font-weight=\"bold\"")
}
if e.Italic == chroma.Yes {
styles = append(styles, "font-style=\"italic\"")
}
if e.Underline == chroma.Yes {
styles = append(styles, "text-decoration=\"underline\"")
}
return strings.Join(styles, " ")
}

View file

@ -0,0 +1,18 @@
package formatters
import (
"fmt"
"io"
"github.com/alecthomas/chroma/v2"
)
// Tokens formatter outputs the raw token structures.
var Tokens = Register("tokens", chroma.FormatterFunc(func(w io.Writer, s *chroma.Style, it chroma.Iterator) error {
for t := it(); t != chroma.EOF; t = it() {
if _, err := fmt.Fprintln(w, t.GoString()); err != nil {
return err
}
}
return nil
}))

View file

@ -0,0 +1,284 @@
package formatters
import (
"io"
"math"
"github.com/alecthomas/chroma/v2"
)
type ttyTable struct {
foreground map[chroma.Colour]string
background map[chroma.Colour]string
}
var c = chroma.MustParseColour
var ttyTables = map[int]*ttyTable{
8: {
foreground: map[chroma.Colour]string{
c("#000000"): "\033[30m", c("#7f0000"): "\033[31m", c("#007f00"): "\033[32m", c("#7f7fe0"): "\033[33m",
c("#00007f"): "\033[34m", c("#7f007f"): "\033[35m", c("#007f7f"): "\033[36m", c("#e5e5e5"): "\033[37m",
c("#555555"): "\033[1m\033[30m", c("#ff0000"): "\033[1m\033[31m", c("#00ff00"): "\033[1m\033[32m", c("#ffff00"): "\033[1m\033[33m",
c("#0000ff"): "\033[1m\033[34m", c("#ff00ff"): "\033[1m\033[35m", c("#00ffff"): "\033[1m\033[36m", c("#ffffff"): "\033[1m\033[37m",
},
background: map[chroma.Colour]string{
c("#000000"): "\033[40m", c("#7f0000"): "\033[41m", c("#007f00"): "\033[42m", c("#7f7fe0"): "\033[43m",
c("#00007f"): "\033[44m", c("#7f007f"): "\033[45m", c("#007f7f"): "\033[46m", c("#e5e5e5"): "\033[47m",
c("#555555"): "\033[1m\033[40m", c("#ff0000"): "\033[1m\033[41m", c("#00ff00"): "\033[1m\033[42m", c("#ffff00"): "\033[1m\033[43m",
c("#0000ff"): "\033[1m\033[44m", c("#ff00ff"): "\033[1m\033[45m", c("#00ffff"): "\033[1m\033[46m", c("#ffffff"): "\033[1m\033[47m",
},
},
16: {
foreground: map[chroma.Colour]string{
c("#000000"): "\033[30m", c("#7f0000"): "\033[31m", c("#007f00"): "\033[32m", c("#7f7fe0"): "\033[33m",
c("#00007f"): "\033[34m", c("#7f007f"): "\033[35m", c("#007f7f"): "\033[36m", c("#e5e5e5"): "\033[37m",
c("#555555"): "\033[90m", c("#ff0000"): "\033[91m", c("#00ff00"): "\033[92m", c("#ffff00"): "\033[93m",
c("#0000ff"): "\033[94m", c("#ff00ff"): "\033[95m", c("#00ffff"): "\033[96m", c("#ffffff"): "\033[97m",
},
background: map[chroma.Colour]string{
c("#000000"): "\033[40m", c("#7f0000"): "\033[41m", c("#007f00"): "\033[42m", c("#7f7fe0"): "\033[43m",
c("#00007f"): "\033[44m", c("#7f007f"): "\033[45m", c("#007f7f"): "\033[46m", c("#e5e5e5"): "\033[47m",
c("#555555"): "\033[100m", c("#ff0000"): "\033[101m", c("#00ff00"): "\033[102m", c("#ffff00"): "\033[103m",
c("#0000ff"): "\033[104m", c("#ff00ff"): "\033[105m", c("#00ffff"): "\033[106m", c("#ffffff"): "\033[107m",
},
},
256: {
foreground: map[chroma.Colour]string{
c("#000000"): "\033[38;5;0m", c("#800000"): "\033[38;5;1m", c("#008000"): "\033[38;5;2m", c("#808000"): "\033[38;5;3m",
c("#000080"): "\033[38;5;4m", c("#800080"): "\033[38;5;5m", c("#008080"): "\033[38;5;6m", c("#c0c0c0"): "\033[38;5;7m",
c("#808080"): "\033[38;5;8m", c("#ff0000"): "\033[38;5;9m", c("#00ff00"): "\033[38;5;10m", c("#ffff00"): "\033[38;5;11m",
c("#0000ff"): "\033[38;5;12m", c("#ff00ff"): "\033[38;5;13m", c("#00ffff"): "\033[38;5;14m", c("#ffffff"): "\033[38;5;15m",
c("#000000"): "\033[38;5;16m", c("#00005f"): "\033[38;5;17m", c("#000087"): "\033[38;5;18m", c("#0000af"): "\033[38;5;19m",
c("#0000d7"): "\033[38;5;20m", c("#0000ff"): "\033[38;5;21m", c("#005f00"): "\033[38;5;22m", c("#005f5f"): "\033[38;5;23m",
c("#005f87"): "\033[38;5;24m", c("#005faf"): "\033[38;5;25m", c("#005fd7"): "\033[38;5;26m", c("#005fff"): "\033[38;5;27m",
c("#008700"): "\033[38;5;28m", c("#00875f"): "\033[38;5;29m", c("#008787"): "\033[38;5;30m", c("#0087af"): "\033[38;5;31m",
c("#0087d7"): "\033[38;5;32m", c("#0087ff"): "\033[38;5;33m", c("#00af00"): "\033[38;5;34m", c("#00af5f"): "\033[38;5;35m",
c("#00af87"): "\033[38;5;36m", c("#00afaf"): "\033[38;5;37m", c("#00afd7"): "\033[38;5;38m", c("#00afff"): "\033[38;5;39m",
c("#00d700"): "\033[38;5;40m", c("#00d75f"): "\033[38;5;41m", c("#00d787"): "\033[38;5;42m", c("#00d7af"): "\033[38;5;43m",
c("#00d7d7"): "\033[38;5;44m", c("#00d7ff"): "\033[38;5;45m", c("#00ff00"): "\033[38;5;46m", c("#00ff5f"): "\033[38;5;47m",
c("#00ff87"): "\033[38;5;48m", c("#00ffaf"): "\033[38;5;49m", c("#00ffd7"): "\033[38;5;50m", c("#00ffff"): "\033[38;5;51m",
c("#5f0000"): "\033[38;5;52m", c("#5f005f"): "\033[38;5;53m", c("#5f0087"): "\033[38;5;54m", c("#5f00af"): "\033[38;5;55m",
c("#5f00d7"): "\033[38;5;56m", c("#5f00ff"): "\033[38;5;57m", c("#5f5f00"): "\033[38;5;58m", c("#5f5f5f"): "\033[38;5;59m",
c("#5f5f87"): "\033[38;5;60m", c("#5f5faf"): "\033[38;5;61m", c("#5f5fd7"): "\033[38;5;62m", c("#5f5fff"): "\033[38;5;63m",
c("#5f8700"): "\033[38;5;64m", c("#5f875f"): "\033[38;5;65m", c("#5f8787"): "\033[38;5;66m", c("#5f87af"): "\033[38;5;67m",
c("#5f87d7"): "\033[38;5;68m", c("#5f87ff"): "\033[38;5;69m", c("#5faf00"): "\033[38;5;70m", c("#5faf5f"): "\033[38;5;71m",
c("#5faf87"): "\033[38;5;72m", c("#5fafaf"): "\033[38;5;73m", c("#5fafd7"): "\033[38;5;74m", c("#5fafff"): "\033[38;5;75m",
c("#5fd700"): "\033[38;5;76m", c("#5fd75f"): "\033[38;5;77m", c("#5fd787"): "\033[38;5;78m", c("#5fd7af"): "\033[38;5;79m",
c("#5fd7d7"): "\033[38;5;80m", c("#5fd7ff"): "\033[38;5;81m", c("#5fff00"): "\033[38;5;82m", c("#5fff5f"): "\033[38;5;83m",
c("#5fff87"): "\033[38;5;84m", c("#5fffaf"): "\033[38;5;85m", c("#5fffd7"): "\033[38;5;86m", c("#5fffff"): "\033[38;5;87m",
c("#870000"): "\033[38;5;88m", c("#87005f"): "\033[38;5;89m", c("#870087"): "\033[38;5;90m", c("#8700af"): "\033[38;5;91m",
c("#8700d7"): "\033[38;5;92m", c("#8700ff"): "\033[38;5;93m", c("#875f00"): "\033[38;5;94m", c("#875f5f"): "\033[38;5;95m",
c("#875f87"): "\033[38;5;96m", c("#875faf"): "\033[38;5;97m", c("#875fd7"): "\033[38;5;98m", c("#875fff"): "\033[38;5;99m",
c("#878700"): "\033[38;5;100m", c("#87875f"): "\033[38;5;101m", c("#878787"): "\033[38;5;102m", c("#8787af"): "\033[38;5;103m",
c("#8787d7"): "\033[38;5;104m", c("#8787ff"): "\033[38;5;105m", c("#87af00"): "\033[38;5;106m", c("#87af5f"): "\033[38;5;107m",
c("#87af87"): "\033[38;5;108m", c("#87afaf"): "\033[38;5;109m", c("#87afd7"): "\033[38;5;110m", c("#87afff"): "\033[38;5;111m",
c("#87d700"): "\033[38;5;112m", c("#87d75f"): "\033[38;5;113m", c("#87d787"): "\033[38;5;114m", c("#87d7af"): "\033[38;5;115m",
c("#87d7d7"): "\033[38;5;116m", c("#87d7ff"): "\033[38;5;117m", c("#87ff00"): "\033[38;5;118m", c("#87ff5f"): "\033[38;5;119m",
c("#87ff87"): "\033[38;5;120m", c("#87ffaf"): "\033[38;5;121m", c("#87ffd7"): "\033[38;5;122m", c("#87ffff"): "\033[38;5;123m",
c("#af0000"): "\033[38;5;124m", c("#af005f"): "\033[38;5;125m", c("#af0087"): "\033[38;5;126m", c("#af00af"): "\033[38;5;127m",
c("#af00d7"): "\033[38;5;128m", c("#af00ff"): "\033[38;5;129m", c("#af5f00"): "\033[38;5;130m", c("#af5f5f"): "\033[38;5;131m",
c("#af5f87"): "\033[38;5;132m", c("#af5faf"): "\033[38;5;133m", c("#af5fd7"): "\033[38;5;134m", c("#af5fff"): "\033[38;5;135m",
c("#af8700"): "\033[38;5;136m", c("#af875f"): "\033[38;5;137m", c("#af8787"): "\033[38;5;138m", c("#af87af"): "\033[38;5;139m",
c("#af87d7"): "\033[38;5;140m", c("#af87ff"): "\033[38;5;141m", c("#afaf00"): "\033[38;5;142m", c("#afaf5f"): "\033[38;5;143m",
c("#afaf87"): "\033[38;5;144m", c("#afafaf"): "\033[38;5;145m", c("#afafd7"): "\033[38;5;146m", c("#afafff"): "\033[38;5;147m",
c("#afd700"): "\033[38;5;148m", c("#afd75f"): "\033[38;5;149m", c("#afd787"): "\033[38;5;150m", c("#afd7af"): "\033[38;5;151m",
c("#afd7d7"): "\033[38;5;152m", c("#afd7ff"): "\033[38;5;153m", c("#afff00"): "\033[38;5;154m", c("#afff5f"): "\033[38;5;155m",
c("#afff87"): "\033[38;5;156m", c("#afffaf"): "\033[38;5;157m", c("#afffd7"): "\033[38;5;158m", c("#afffff"): "\033[38;5;159m",
c("#d70000"): "\033[38;5;160m", c("#d7005f"): "\033[38;5;161m", c("#d70087"): "\033[38;5;162m", c("#d700af"): "\033[38;5;163m",
c("#d700d7"): "\033[38;5;164m", c("#d700ff"): "\033[38;5;165m", c("#d75f00"): "\033[38;5;166m", c("#d75f5f"): "\033[38;5;167m",
c("#d75f87"): "\033[38;5;168m", c("#d75faf"): "\033[38;5;169m", c("#d75fd7"): "\033[38;5;170m", c("#d75fff"): "\033[38;5;171m",
c("#d78700"): "\033[38;5;172m", c("#d7875f"): "\033[38;5;173m", c("#d78787"): "\033[38;5;174m", c("#d787af"): "\033[38;5;175m",
c("#d787d7"): "\033[38;5;176m", c("#d787ff"): "\033[38;5;177m", c("#d7af00"): "\033[38;5;178m", c("#d7af5f"): "\033[38;5;179m",
c("#d7af87"): "\033[38;5;180m", c("#d7afaf"): "\033[38;5;181m", c("#d7afd7"): "\033[38;5;182m", c("#d7afff"): "\033[38;5;183m",
c("#d7d700"): "\033[38;5;184m", c("#d7d75f"): "\033[38;5;185m", c("#d7d787"): "\033[38;5;186m", c("#d7d7af"): "\033[38;5;187m",
c("#d7d7d7"): "\033[38;5;188m", c("#d7d7ff"): "\033[38;5;189m", c("#d7ff00"): "\033[38;5;190m", c("#d7ff5f"): "\033[38;5;191m",
c("#d7ff87"): "\033[38;5;192m", c("#d7ffaf"): "\033[38;5;193m", c("#d7ffd7"): "\033[38;5;194m", c("#d7ffff"): "\033[38;5;195m",
c("#ff0000"): "\033[38;5;196m", c("#ff005f"): "\033[38;5;197m", c("#ff0087"): "\033[38;5;198m", c("#ff00af"): "\033[38;5;199m",
c("#ff00d7"): "\033[38;5;200m", c("#ff00ff"): "\033[38;5;201m", c("#ff5f00"): "\033[38;5;202m", c("#ff5f5f"): "\033[38;5;203m",
c("#ff5f87"): "\033[38;5;204m", c("#ff5faf"): "\033[38;5;205m", c("#ff5fd7"): "\033[38;5;206m", c("#ff5fff"): "\033[38;5;207m",
c("#ff8700"): "\033[38;5;208m", c("#ff875f"): "\033[38;5;209m", c("#ff8787"): "\033[38;5;210m", c("#ff87af"): "\033[38;5;211m",
c("#ff87d7"): "\033[38;5;212m", c("#ff87ff"): "\033[38;5;213m", c("#ffaf00"): "\033[38;5;214m", c("#ffaf5f"): "\033[38;5;215m",
c("#ffaf87"): "\033[38;5;216m", c("#ffafaf"): "\033[38;5;217m", c("#ffafd7"): "\033[38;5;218m", c("#ffafff"): "\033[38;5;219m",
c("#ffd700"): "\033[38;5;220m", c("#ffd75f"): "\033[38;5;221m", c("#ffd787"): "\033[38;5;222m", c("#ffd7af"): "\033[38;5;223m",
c("#ffd7d7"): "\033[38;5;224m", c("#ffd7ff"): "\033[38;5;225m", c("#ffff00"): "\033[38;5;226m", c("#ffff5f"): "\033[38;5;227m",
c("#ffff87"): "\033[38;5;228m", c("#ffffaf"): "\033[38;5;229m", c("#ffffd7"): "\033[38;5;230m", c("#ffffff"): "\033[38;5;231m",
c("#080808"): "\033[38;5;232m", c("#121212"): "\033[38;5;233m", c("#1c1c1c"): "\033[38;5;234m", c("#262626"): "\033[38;5;235m",
c("#303030"): "\033[38;5;236m", c("#3a3a3a"): "\033[38;5;237m", c("#444444"): "\033[38;5;238m", c("#4e4e4e"): "\033[38;5;239m",
c("#585858"): "\033[38;5;240m", c("#626262"): "\033[38;5;241m", c("#6c6c6c"): "\033[38;5;242m", c("#767676"): "\033[38;5;243m",
c("#808080"): "\033[38;5;244m", c("#8a8a8a"): "\033[38;5;245m", c("#949494"): "\033[38;5;246m", c("#9e9e9e"): "\033[38;5;247m",
c("#a8a8a8"): "\033[38;5;248m", c("#b2b2b2"): "\033[38;5;249m", c("#bcbcbc"): "\033[38;5;250m", c("#c6c6c6"): "\033[38;5;251m",
c("#d0d0d0"): "\033[38;5;252m", c("#dadada"): "\033[38;5;253m", c("#e4e4e4"): "\033[38;5;254m", c("#eeeeee"): "\033[38;5;255m",
},
background: map[chroma.Colour]string{
c("#000000"): "\033[48;5;0m", c("#800000"): "\033[48;5;1m", c("#008000"): "\033[48;5;2m", c("#808000"): "\033[48;5;3m",
c("#000080"): "\033[48;5;4m", c("#800080"): "\033[48;5;5m", c("#008080"): "\033[48;5;6m", c("#c0c0c0"): "\033[48;5;7m",
c("#808080"): "\033[48;5;8m", c("#ff0000"): "\033[48;5;9m", c("#00ff00"): "\033[48;5;10m", c("#ffff00"): "\033[48;5;11m",
c("#0000ff"): "\033[48;5;12m", c("#ff00ff"): "\033[48;5;13m", c("#00ffff"): "\033[48;5;14m", c("#ffffff"): "\033[48;5;15m",
c("#000000"): "\033[48;5;16m", c("#00005f"): "\033[48;5;17m", c("#000087"): "\033[48;5;18m", c("#0000af"): "\033[48;5;19m",
c("#0000d7"): "\033[48;5;20m", c("#0000ff"): "\033[48;5;21m", c("#005f00"): "\033[48;5;22m", c("#005f5f"): "\033[48;5;23m",
c("#005f87"): "\033[48;5;24m", c("#005faf"): "\033[48;5;25m", c("#005fd7"): "\033[48;5;26m", c("#005fff"): "\033[48;5;27m",
c("#008700"): "\033[48;5;28m", c("#00875f"): "\033[48;5;29m", c("#008787"): "\033[48;5;30m", c("#0087af"): "\033[48;5;31m",
c("#0087d7"): "\033[48;5;32m", c("#0087ff"): "\033[48;5;33m", c("#00af00"): "\033[48;5;34m", c("#00af5f"): "\033[48;5;35m",
c("#00af87"): "\033[48;5;36m", c("#00afaf"): "\033[48;5;37m", c("#00afd7"): "\033[48;5;38m", c("#00afff"): "\033[48;5;39m",
c("#00d700"): "\033[48;5;40m", c("#00d75f"): "\033[48;5;41m", c("#00d787"): "\033[48;5;42m", c("#00d7af"): "\033[48;5;43m",
c("#00d7d7"): "\033[48;5;44m", c("#00d7ff"): "\033[48;5;45m", c("#00ff00"): "\033[48;5;46m", c("#00ff5f"): "\033[48;5;47m",
c("#00ff87"): "\033[48;5;48m", c("#00ffaf"): "\033[48;5;49m", c("#00ffd7"): "\033[48;5;50m", c("#00ffff"): "\033[48;5;51m",
c("#5f0000"): "\033[48;5;52m", c("#5f005f"): "\033[48;5;53m", c("#5f0087"): "\033[48;5;54m", c("#5f00af"): "\033[48;5;55m",
c("#5f00d7"): "\033[48;5;56m", c("#5f00ff"): "\033[48;5;57m", c("#5f5f00"): "\033[48;5;58m", c("#5f5f5f"): "\033[48;5;59m",
c("#5f5f87"): "\033[48;5;60m", c("#5f5faf"): "\033[48;5;61m", c("#5f5fd7"): "\033[48;5;62m", c("#5f5fff"): "\033[48;5;63m",
c("#5f8700"): "\033[48;5;64m", c("#5f875f"): "\033[48;5;65m", c("#5f8787"): "\033[48;5;66m", c("#5f87af"): "\033[48;5;67m",
c("#5f87d7"): "\033[48;5;68m", c("#5f87ff"): "\033[48;5;69m", c("#5faf00"): "\033[48;5;70m", c("#5faf5f"): "\033[48;5;71m",
c("#5faf87"): "\033[48;5;72m", c("#5fafaf"): "\033[48;5;73m", c("#5fafd7"): "\033[48;5;74m", c("#5fafff"): "\033[48;5;75m",
c("#5fd700"): "\033[48;5;76m", c("#5fd75f"): "\033[48;5;77m", c("#5fd787"): "\033[48;5;78m", c("#5fd7af"): "\033[48;5;79m",
c("#5fd7d7"): "\033[48;5;80m", c("#5fd7ff"): "\033[48;5;81m", c("#5fff00"): "\033[48;5;82m", c("#5fff5f"): "\033[48;5;83m",
c("#5fff87"): "\033[48;5;84m", c("#5fffaf"): "\033[48;5;85m", c("#5fffd7"): "\033[48;5;86m", c("#5fffff"): "\033[48;5;87m",
c("#870000"): "\033[48;5;88m", c("#87005f"): "\033[48;5;89m", c("#870087"): "\033[48;5;90m", c("#8700af"): "\033[48;5;91m",
c("#8700d7"): "\033[48;5;92m", c("#8700ff"): "\033[48;5;93m", c("#875f00"): "\033[48;5;94m", c("#875f5f"): "\033[48;5;95m",
c("#875f87"): "\033[48;5;96m", c("#875faf"): "\033[48;5;97m", c("#875fd7"): "\033[48;5;98m", c("#875fff"): "\033[48;5;99m",
c("#878700"): "\033[48;5;100m", c("#87875f"): "\033[48;5;101m", c("#878787"): "\033[48;5;102m", c("#8787af"): "\033[48;5;103m",
c("#8787d7"): "\033[48;5;104m", c("#8787ff"): "\033[48;5;105m", c("#87af00"): "\033[48;5;106m", c("#87af5f"): "\033[48;5;107m",
c("#87af87"): "\033[48;5;108m", c("#87afaf"): "\033[48;5;109m", c("#87afd7"): "\033[48;5;110m", c("#87afff"): "\033[48;5;111m",
c("#87d700"): "\033[48;5;112m", c("#87d75f"): "\033[48;5;113m", c("#87d787"): "\033[48;5;114m", c("#87d7af"): "\033[48;5;115m",
c("#87d7d7"): "\033[48;5;116m", c("#87d7ff"): "\033[48;5;117m", c("#87ff00"): "\033[48;5;118m", c("#87ff5f"): "\033[48;5;119m",
c("#87ff87"): "\033[48;5;120m", c("#87ffaf"): "\033[48;5;121m", c("#87ffd7"): "\033[48;5;122m", c("#87ffff"): "\033[48;5;123m",
c("#af0000"): "\033[48;5;124m", c("#af005f"): "\033[48;5;125m", c("#af0087"): "\033[48;5;126m", c("#af00af"): "\033[48;5;127m",
c("#af00d7"): "\033[48;5;128m", c("#af00ff"): "\033[48;5;129m", c("#af5f00"): "\033[48;5;130m", c("#af5f5f"): "\033[48;5;131m",
c("#af5f87"): "\033[48;5;132m", c("#af5faf"): "\033[48;5;133m", c("#af5fd7"): "\033[48;5;134m", c("#af5fff"): "\033[48;5;135m",
c("#af8700"): "\033[48;5;136m", c("#af875f"): "\033[48;5;137m", c("#af8787"): "\033[48;5;138m", c("#af87af"): "\033[48;5;139m",
c("#af87d7"): "\033[48;5;140m", c("#af87ff"): "\033[48;5;141m", c("#afaf00"): "\033[48;5;142m", c("#afaf5f"): "\033[48;5;143m",
c("#afaf87"): "\033[48;5;144m", c("#afafaf"): "\033[48;5;145m", c("#afafd7"): "\033[48;5;146m", c("#afafff"): "\033[48;5;147m",
c("#afd700"): "\033[48;5;148m", c("#afd75f"): "\033[48;5;149m", c("#afd787"): "\033[48;5;150m", c("#afd7af"): "\033[48;5;151m",
c("#afd7d7"): "\033[48;5;152m", c("#afd7ff"): "\033[48;5;153m", c("#afff00"): "\033[48;5;154m", c("#afff5f"): "\033[48;5;155m",
c("#afff87"): "\033[48;5;156m", c("#afffaf"): "\033[48;5;157m", c("#afffd7"): "\033[48;5;158m", c("#afffff"): "\033[48;5;159m",
c("#d70000"): "\033[48;5;160m", c("#d7005f"): "\033[48;5;161m", c("#d70087"): "\033[48;5;162m", c("#d700af"): "\033[48;5;163m",
c("#d700d7"): "\033[48;5;164m", c("#d700ff"): "\033[48;5;165m", c("#d75f00"): "\033[48;5;166m", c("#d75f5f"): "\033[48;5;167m",
c("#d75f87"): "\033[48;5;168m", c("#d75faf"): "\033[48;5;169m", c("#d75fd7"): "\033[48;5;170m", c("#d75fff"): "\033[48;5;171m",
c("#d78700"): "\033[48;5;172m", c("#d7875f"): "\033[48;5;173m", c("#d78787"): "\033[48;5;174m", c("#d787af"): "\033[48;5;175m",
c("#d787d7"): "\033[48;5;176m", c("#d787ff"): "\033[48;5;177m", c("#d7af00"): "\033[48;5;178m", c("#d7af5f"): "\033[48;5;179m",
c("#d7af87"): "\033[48;5;180m", c("#d7afaf"): "\033[48;5;181m", c("#d7afd7"): "\033[48;5;182m", c("#d7afff"): "\033[48;5;183m",
c("#d7d700"): "\033[48;5;184m", c("#d7d75f"): "\033[48;5;185m", c("#d7d787"): "\033[48;5;186m", c("#d7d7af"): "\033[48;5;187m",
c("#d7d7d7"): "\033[48;5;188m", c("#d7d7ff"): "\033[48;5;189m", c("#d7ff00"): "\033[48;5;190m", c("#d7ff5f"): "\033[48;5;191m",
c("#d7ff87"): "\033[48;5;192m", c("#d7ffaf"): "\033[48;5;193m", c("#d7ffd7"): "\033[48;5;194m", c("#d7ffff"): "\033[48;5;195m",
c("#ff0000"): "\033[48;5;196m", c("#ff005f"): "\033[48;5;197m", c("#ff0087"): "\033[48;5;198m", c("#ff00af"): "\033[48;5;199m",
c("#ff00d7"): "\033[48;5;200m", c("#ff00ff"): "\033[48;5;201m", c("#ff5f00"): "\033[48;5;202m", c("#ff5f5f"): "\033[48;5;203m",
c("#ff5f87"): "\033[48;5;204m", c("#ff5faf"): "\033[48;5;205m", c("#ff5fd7"): "\033[48;5;206m", c("#ff5fff"): "\033[48;5;207m",
c("#ff8700"): "\033[48;5;208m", c("#ff875f"): "\033[48;5;209m", c("#ff8787"): "\033[48;5;210m", c("#ff87af"): "\033[48;5;211m",
c("#ff87d7"): "\033[48;5;212m", c("#ff87ff"): "\033[48;5;213m", c("#ffaf00"): "\033[48;5;214m", c("#ffaf5f"): "\033[48;5;215m",
c("#ffaf87"): "\033[48;5;216m", c("#ffafaf"): "\033[48;5;217m", c("#ffafd7"): "\033[48;5;218m", c("#ffafff"): "\033[48;5;219m",
c("#ffd700"): "\033[48;5;220m", c("#ffd75f"): "\033[48;5;221m", c("#ffd787"): "\033[48;5;222m", c("#ffd7af"): "\033[48;5;223m",
c("#ffd7d7"): "\033[48;5;224m", c("#ffd7ff"): "\033[48;5;225m", c("#ffff00"): "\033[48;5;226m", c("#ffff5f"): "\033[48;5;227m",
c("#ffff87"): "\033[48;5;228m", c("#ffffaf"): "\033[48;5;229m", c("#ffffd7"): "\033[48;5;230m", c("#ffffff"): "\033[48;5;231m",
c("#080808"): "\033[48;5;232m", c("#121212"): "\033[48;5;233m", c("#1c1c1c"): "\033[48;5;234m", c("#262626"): "\033[48;5;235m",
c("#303030"): "\033[48;5;236m", c("#3a3a3a"): "\033[48;5;237m", c("#444444"): "\033[48;5;238m", c("#4e4e4e"): "\033[48;5;239m",
c("#585858"): "\033[48;5;240m", c("#626262"): "\033[48;5;241m", c("#6c6c6c"): "\033[48;5;242m", c("#767676"): "\033[48;5;243m",
c("#808080"): "\033[48;5;244m", c("#8a8a8a"): "\033[48;5;245m", c("#949494"): "\033[48;5;246m", c("#9e9e9e"): "\033[48;5;247m",
c("#a8a8a8"): "\033[48;5;248m", c("#b2b2b2"): "\033[48;5;249m", c("#bcbcbc"): "\033[48;5;250m", c("#c6c6c6"): "\033[48;5;251m",
c("#d0d0d0"): "\033[48;5;252m", c("#dadada"): "\033[48;5;253m", c("#e4e4e4"): "\033[48;5;254m", c("#eeeeee"): "\033[48;5;255m",
},
},
}
func entryToEscapeSequence(table *ttyTable, entry chroma.StyleEntry) string {
out := ""
if entry.Bold == chroma.Yes {
out += "\033[1m"
}
if entry.Underline == chroma.Yes {
out += "\033[4m"
}
if entry.Italic == chroma.Yes {
out += "\033[3m"
}
if entry.Colour.IsSet() {
out += table.foreground[findClosest(table, entry.Colour)]
}
if entry.Background.IsSet() {
out += table.background[findClosest(table, entry.Background)]
}
return out
}
func findClosest(table *ttyTable, seeking chroma.Colour) chroma.Colour {
closestColour := chroma.Colour(0)
closest := float64(math.MaxFloat64)
for colour := range table.foreground {
distance := colour.Distance(seeking)
if distance < closest {
closest = distance
closestColour = colour
}
}
return closestColour
}
func styleToEscapeSequence(table *ttyTable, style *chroma.Style) map[chroma.TokenType]string {
style = clearBackground(style)
out := map[chroma.TokenType]string{}
for _, ttype := range style.Types() {
entry := style.Get(ttype)
out[ttype] = entryToEscapeSequence(table, entry)
}
return out
}
// Clear the background colour.
func clearBackground(style *chroma.Style) *chroma.Style {
builder := style.Builder()
bg := builder.Get(chroma.Background)
bg.Background = 0
bg.NoInherit = true
builder.AddEntry(chroma.Background, bg)
style, _ = builder.Build()
return style
}
type indexedTTYFormatter struct {
table *ttyTable
}
func (c *indexedTTYFormatter) Format(w io.Writer, style *chroma.Style, it chroma.Iterator) (err error) {
theme := styleToEscapeSequence(c.table, style)
for token := it(); token != chroma.EOF; token = it() {
clr, ok := theme[token.Type]
// This search mimics how styles.Get() is used in tty_truecolour.go.
if !ok {
clr, ok = theme[token.Type.SubCategory()]
if !ok {
clr, ok = theme[token.Type.Category()]
if !ok {
clr, ok = theme[chroma.Text]
if !ok {
clr = theme[chroma.Background]
}
}
}
}
writeToken(w, clr, token.Value)
}
return nil
}
// TTY is an 8-colour terminal formatter.
//
// The Lab colour space is used to map RGB values to the most appropriate index colour.
var TTY = Register("terminal", &indexedTTYFormatter{ttyTables[8]})
// TTY8 is an 8-colour terminal formatter.
//
// The Lab colour space is used to map RGB values to the most appropriate index colour.
var TTY8 = Register("terminal8", &indexedTTYFormatter{ttyTables[8]})
// TTY16 is a 16-colour terminal formatter.
//
// It uses \033[3xm for normal colours and \033[90Xm for bright colours.
//
// The Lab colour space is used to map RGB values to the most appropriate index colour.
var TTY16 = Register("terminal16", &indexedTTYFormatter{ttyTables[16]})
// TTY256 is a 256-colour terminal formatter.
//
// The Lab colour space is used to map RGB values to the most appropriate index colour.
var TTY256 = Register("terminal256", &indexedTTYFormatter{ttyTables[256]})

View file

@ -0,0 +1,76 @@
package formatters
import (
"fmt"
"io"
"regexp"
"github.com/alecthomas/chroma/v2"
)
// TTY16m is a true-colour terminal formatter.
var TTY16m = Register("terminal16m", chroma.FormatterFunc(trueColourFormatter))
var crOrCrLf = regexp.MustCompile(`\r?\n`)
// Print the text with the given formatting, resetting the formatting at the end
// of each line and resuming it on the next line.
//
// This way, a pager (like https://github.com/walles/moar for example) can show
// any line in the output by itself, and it will get the right formatting.
func writeToken(w io.Writer, formatting string, text string) {
if formatting == "" {
fmt.Fprint(w, text)
return
}
newlineIndices := crOrCrLf.FindAllStringIndex(text, -1)
afterLastNewline := 0
for _, indices := range newlineIndices {
newlineStart, afterNewline := indices[0], indices[1]
fmt.Fprint(w, formatting)
fmt.Fprint(w, text[afterLastNewline:newlineStart])
fmt.Fprint(w, "\033[0m")
fmt.Fprint(w, text[newlineStart:afterNewline])
afterLastNewline = afterNewline
}
if afterLastNewline < len(text) {
// Print whatever is left after the last newline
fmt.Fprint(w, formatting)
fmt.Fprint(w, text[afterLastNewline:])
fmt.Fprint(w, "\033[0m")
}
}
func trueColourFormatter(w io.Writer, style *chroma.Style, it chroma.Iterator) error {
style = clearBackground(style)
for token := it(); token != chroma.EOF; token = it() {
entry := style.Get(token.Type)
if entry.IsZero() {
fmt.Fprint(w, token.Value)
continue
}
formatting := ""
if entry.Bold == chroma.Yes {
formatting += "\033[1m"
}
if entry.Underline == chroma.Yes {
formatting += "\033[4m"
}
if entry.Italic == chroma.Yes {
formatting += "\033[3m"
}
if entry.Colour.IsSet() {
formatting += fmt.Sprintf("\033[38;2;%d;%d;%dm", entry.Colour.Red(), entry.Colour.Green(), entry.Colour.Blue())
}
if entry.Background.IsSet() {
formatting += fmt.Sprintf("\033[48;2;%d;%d;%dm", entry.Background.Red(), entry.Background.Green(), entry.Background.Blue())
}
writeToken(w, formatting, token.Value)
}
return nil
}

View file

@ -0,0 +1,93 @@
package chroma
import "strings"
// An Iterator across tokens.
//
// EOF will be returned at the end of the Token stream.
//
// If an error occurs within an Iterator, it may propagate this in a panic. Formatters should recover.
type Iterator func() Token
// Tokens consumes all tokens from the iterator and returns them as a slice.
func (i Iterator) Tokens() []Token {
var out []Token
for t := i(); t != EOF; t = i() {
out = append(out, t)
}
return out
}
// Stdlib converts a Chroma iterator to a Go 1.23-compatible iterator.
func (i Iterator) Stdlib() func(yield func(Token) bool) {
return func(yield func(Token) bool) {
for t := i(); t != EOF; t = i() {
if !yield(t) {
return
}
}
}
}
// Concaterator concatenates tokens from a series of iterators.
func Concaterator(iterators ...Iterator) Iterator {
return func() Token {
for len(iterators) > 0 {
t := iterators[0]()
if t != EOF {
return t
}
iterators = iterators[1:]
}
return EOF
}
}
// Literator converts a sequence of literal Tokens into an Iterator.
func Literator(tokens ...Token) Iterator {
return func() Token {
if len(tokens) == 0 {
return EOF
}
token := tokens[0]
tokens = tokens[1:]
return token
}
}
// SplitTokensIntoLines splits tokens containing newlines in two.
func SplitTokensIntoLines(tokens []Token) (out [][]Token) {
var line []Token // nolint: prealloc
tokenLoop:
for _, token := range tokens {
for strings.Contains(token.Value, "\n") {
parts := strings.SplitAfterN(token.Value, "\n", 2)
// Token becomes the tail.
token.Value = parts[1]
// Append the head to the line and flush the line.
clone := token.Clone()
clone.Value = parts[0]
line = append(line, clone)
out = append(out, line)
line = nil
// If the tail token is empty, don't emit it.
if len(token.Value) == 0 {
continue tokenLoop
}
}
line = append(line, token)
}
if len(line) > 0 {
out = append(out, line)
}
// Strip empty trailing token line.
if len(out) > 0 {
last := out[len(out)-1]
if len(last) == 1 && last[0].Value == "" {
out = out[:len(out)-1]
}
}
return out
}

View file

@ -0,0 +1,179 @@
package chroma
import (
"fmt"
"strings"
)
var (
defaultOptions = &TokeniseOptions{
State: "root",
EnsureLF: true,
}
)
// Config for a lexer.
type Config struct {
// Name of the lexer.
Name string `xml:"name,omitempty"`
// Shortcuts for the lexer
Aliases []string `xml:"alias,omitempty"`
// File name globs
Filenames []string `xml:"filename,omitempty"`
// Secondary file name globs
AliasFilenames []string `xml:"alias_filename,omitempty"`
// MIME types
MimeTypes []string `xml:"mime_type,omitempty"`
// Regex matching is case-insensitive.
CaseInsensitive bool `xml:"case_insensitive,omitempty"`
// Regex matches all characters.
DotAll bool `xml:"dot_all,omitempty"`
// Regex does not match across lines ($ matches EOL).
//
// Defaults to multiline.
NotMultiline bool `xml:"not_multiline,omitempty"`
// Don't strip leading and trailing newlines from the input.
// DontStripNL bool
// Strip all leading and trailing whitespace from the input
// StripAll bool
// Make sure that the input ends with a newline. This
// is required for some lexers that consume input linewise.
EnsureNL bool `xml:"ensure_nl,omitempty"`
// If given and greater than 0, expand tabs in the input.
// TabSize int
// Priority of lexer.
//
// If this is 0 it will be treated as a default of 1.
Priority float32 `xml:"priority,omitempty"`
// Analyse is a list of regexes to match against the input.
//
// If a match is found, the score is returned if single attribute is set to true,
// otherwise the sum of all the score of matching patterns will be
// used as the final score.
Analyse *AnalyseConfig `xml:"analyse,omitempty"`
}
// AnalyseConfig defines the list of regexes analysers.
type AnalyseConfig struct {
Regexes []RegexConfig `xml:"regex,omitempty"`
// If true, the first matching score is returned.
First bool `xml:"first,attr"`
}
// RegexConfig defines a single regex pattern and its score in case of match.
type RegexConfig struct {
Pattern string `xml:"pattern,attr"`
Score float32 `xml:"score,attr"`
}
// Token output to formatter.
type Token struct {
Type TokenType `json:"type"`
Value string `json:"value"`
}
func (t *Token) String() string { return t.Value }
func (t *Token) GoString() string { return fmt.Sprintf("&Token{%s, %q}", t.Type, t.Value) }
// Clone returns a clone of the Token.
func (t *Token) Clone() Token {
return *t
}
// EOF is returned by lexers at the end of input.
var EOF Token
// TokeniseOptions contains options for tokenisers.
type TokeniseOptions struct {
// State to start tokenisation in. Defaults to "root".
State string
// Nested tokenisation.
Nested bool
// If true, all EOLs are converted into LF
// by replacing CRLF and CR
EnsureLF bool
}
// A Lexer for tokenising source code.
type Lexer interface {
// Config describing the features of the Lexer.
Config() *Config
// Tokenise returns an Iterator over tokens in text.
Tokenise(options *TokeniseOptions, text string) (Iterator, error)
// SetRegistry sets the registry this Lexer is associated with.
//
// The registry should be used by the Lexer if it needs to look up other
// lexers.
SetRegistry(registry *LexerRegistry) Lexer
// SetAnalyser sets a function the Lexer should use for scoring how
// likely a fragment of text is to match this lexer, between 0.0 and 1.0.
// A value of 1 indicates high confidence.
//
// Lexers may ignore this if they implement their own analysers.
SetAnalyser(analyser func(text string) float32) Lexer
// AnalyseText scores how likely a fragment of text is to match
// this lexer, between 0.0 and 1.0. A value of 1 indicates high confidence.
AnalyseText(text string) float32
}
// Trace is the trace of a tokenisation process.
type Trace struct {
Lexer string `json:"lexer"`
State string `json:"state"`
Rule int `json:"rule"`
Pattern string `json:"pattern"`
Pos int `json:"pos"`
Length int `json:"length"`
Elapsed float64 `json:"elapsedMs"` // Elapsed time spent matching for this rule.
}
// TracingLexer is a Lexer that can trace its tokenisation process.
type TracingLexer interface {
Lexer
SetTracing(enable bool)
}
// Lexers is a slice of lexers sortable by name.
type Lexers []Lexer
func (l Lexers) Len() int { return len(l) }
func (l Lexers) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
func (l Lexers) Less(i, j int) bool {
return strings.ToLower(l[i].Config().Name) < strings.ToLower(l[j].Config().Name)
}
// PrioritisedLexers is a slice of lexers sortable by priority.
type PrioritisedLexers []Lexer
func (l PrioritisedLexers) Len() int { return len(l) }
func (l PrioritisedLexers) Swap(i, j int) { l[i], l[j] = l[j], l[i] }
func (l PrioritisedLexers) Less(i, j int) bool {
ip := l[i].Config().Priority
if ip == 0 {
ip = 1
}
jp := l[j].Config().Priority
if jp == 0 {
jp = 1
}
return ip > jp
}
// Analyser determines how appropriate this lexer is for the given text.
type Analyser interface {
AnalyseText(text string) float32
}

View file

@ -0,0 +1,46 @@
# Chroma lexers
All lexers in Chroma should now be defined in XML unless they require custom code.
## Lexer tests
The tests in this directory feed a known input `testdata/<name>.actual` into the parser for `<name>` and check
that its output matches `<name>.expected`.
It is also possible to perform several tests on a same parser `<name>`, by placing know inputs `*.actual` into a
directory `testdata/<name>/`.
### Running the tests
Run the tests as normal:
```go
go test ./lexers
```
### Update existing tests
When you add a new test data file (`*.actual`), you need to regenerate all tests. That's how Chroma creates the `*.expected` test file based on the corresponding lexer.
To regenerate all tests, type in your terminal:
```go
RECORD=true go test ./lexers
```
This first sets the `RECORD` environment variable to `true`. Then it runs `go test` on the `./lexers` directory of the Chroma project.
(That environment variable tells Chroma it needs to output test data. After running `go test ./lexers` you can remove or reset that variable.)
#### Windows users
Windows users will find that the `RECORD=true go test ./lexers` command fails in both the standard command prompt terminal and in PowerShell.
Instead we have to perform both steps separately:
- Set the `RECORD` environment variable to `true`.
+ In the regular command prompt window, the `set` command sets an environment variable for the current session: `set RECORD=true`. See [this page](https://superuser.com/questions/212150/how-to-set-env-variable-in-windows-cmd-line) for more.
+ In PowerShell, you can use the `$env:RECORD = 'true'` command for that. See [this article](https://mcpmag.com/articles/2019/03/28/environment-variables-in-powershell.aspx) for more.
+ You can also make a persistent environment variable by hand in the Windows computer settings. See [this article](https://www.computerhope.com/issues/ch000549.htm) for how.
- When the environment variable is set, run `go test ./lexers`.
Chroma will now regenerate the test files and print its results to the console window.

View file

@ -0,0 +1,275 @@
package lexers
import (
. "github.com/alecthomas/chroma/v2" // nolint
)
// Matcher token stub for docs, or
// Named matcher: @name, or
// Path matcher: /foo, or
// Wildcard path matcher: *
// nolint: gosec
var caddyfileMatcherTokenRegexp = `(\[\<matcher\>\]|@[^\s]+|/[^\s]+|\*)`
// Comment at start of line, or
// Comment preceded by whitespace
var caddyfileCommentRegexp = `(^|\s+)#.*\n`
// caddyfileCommon are the rules common to both of the lexer variants
func caddyfileCommonRules() Rules {
return Rules{
"site_block_common": {
Include("site_body"),
// Any other directive
{`[^\s#]+`, Keyword, Push("directive")},
Include("base"),
},
"site_body": {
// Import keyword
{`\b(import|invoke)\b( [^\s#]+)`, ByGroups(Keyword, Text), Push("subdirective")},
// Matcher definition
{`@[^\s]+(?=\s)`, NameDecorator, Push("matcher")},
// Matcher token stub for docs
{`\[\<matcher\>\]`, NameDecorator, Push("matcher")},
// These cannot have matchers but may have things that look like
// matchers in their arguments, so we just parse as a subdirective.
{`\b(try_files|tls|log|bind)\b`, Keyword, Push("subdirective")},
// These are special, they can nest more directives
{`\b(handle_errors|handle_path|handle_response|replace_status|handle|route)\b`, Keyword, Push("nested_directive")},
// uri directive has special syntax
{`\b(uri)\b`, Keyword, Push("uri_directive")},
},
"matcher": {
{`\{`, Punctuation, Push("block")},
// Not can be one-liner
{`not`, Keyword, Push("deep_not_matcher")},
// Heredoc for CEL expression
Include("heredoc"),
// Backtick for CEL expression
{"`", StringBacktick, Push("backticks")},
// Any other same-line matcher
{`[^\s#]+`, Keyword, Push("arguments")},
// Terminators
{`\s*\n`, Text, Pop(1)},
{`\}`, Punctuation, Pop(1)},
Include("base"),
},
"block": {
{`\}`, Punctuation, Pop(2)},
// Using double quotes doesn't stop at spaces
{`"`, StringDouble, Push("double_quotes")},
// Using backticks doesn't stop at spaces
{"`", StringBacktick, Push("backticks")},
// Not can be one-liner
{`not`, Keyword, Push("not_matcher")},
// Directives & matcher definitions
Include("site_body"),
// Any directive
{`[^\s#]+`, Keyword, Push("subdirective")},
Include("base"),
},
"nested_block": {
{`\}`, Punctuation, Pop(2)},
// Using double quotes doesn't stop at spaces
{`"`, StringDouble, Push("double_quotes")},
// Using backticks doesn't stop at spaces
{"`", StringBacktick, Push("backticks")},
// Not can be one-liner
{`not`, Keyword, Push("not_matcher")},
// Directives & matcher definitions
Include("site_body"),
// Any other subdirective
{`[^\s#]+`, Keyword, Push("directive")},
Include("base"),
},
"not_matcher": {
{`\}`, Punctuation, Pop(2)},
{`\{(?=\s)`, Punctuation, Push("block")},
{`[^\s#]+`, Keyword, Push("arguments")},
{`\s+`, Text, nil},
},
"deep_not_matcher": {
{`\}`, Punctuation, Pop(2)},
{`\{(?=\s)`, Punctuation, Push("block")},
{`[^\s#]+`, Keyword, Push("deep_subdirective")},
{`\s+`, Text, nil},
},
"directive": {
{`\{(?=\s)`, Punctuation, Push("block")},
{caddyfileMatcherTokenRegexp, NameDecorator, Push("arguments")},
{caddyfileCommentRegexp, CommentSingle, Pop(1)},
{`\s*\n`, Text, Pop(1)},
Include("base"),
},
"nested_directive": {
{`\{(?=\s)`, Punctuation, Push("nested_block")},
{caddyfileMatcherTokenRegexp, NameDecorator, Push("nested_arguments")},
{caddyfileCommentRegexp, CommentSingle, Pop(1)},
{`\s*\n`, Text, Pop(1)},
Include("base"),
},
"subdirective": {
{`\{(?=\s)`, Punctuation, Push("block")},
{caddyfileCommentRegexp, CommentSingle, Pop(1)},
{`\s*\n`, Text, Pop(1)},
Include("base"),
},
"arguments": {
{`\{(?=\s)`, Punctuation, Push("block")},
{caddyfileCommentRegexp, CommentSingle, Pop(2)},
{`\\\n`, Text, nil}, // Skip escaped newlines
{`\s*\n`, Text, Pop(2)},
Include("base"),
},
"nested_arguments": {
{`\{(?=\s)`, Punctuation, Push("nested_block")},
{caddyfileCommentRegexp, CommentSingle, Pop(2)},
{`\\\n`, Text, nil}, // Skip escaped newlines
{`\s*\n`, Text, Pop(2)},
Include("base"),
},
"deep_subdirective": {
{`\{(?=\s)`, Punctuation, Push("block")},
{caddyfileCommentRegexp, CommentSingle, Pop(3)},
{`\s*\n`, Text, Pop(3)},
Include("base"),
},
"uri_directive": {
{`\{(?=\s)`, Punctuation, Push("block")},
{caddyfileMatcherTokenRegexp, NameDecorator, nil},
{`(strip_prefix|strip_suffix|replace|path_regexp)`, NameConstant, Push("arguments")},
{caddyfileCommentRegexp, CommentSingle, Pop(1)},
{`\s*\n`, Text, Pop(1)},
Include("base"),
},
"double_quotes": {
Include("placeholder"),
{`\\"`, StringDouble, nil},
{`[^"]`, StringDouble, nil},
{`"`, StringDouble, Pop(1)},
},
"backticks": {
Include("placeholder"),
{"\\\\`", StringBacktick, nil},
{"[^`]", StringBacktick, nil},
{"`", StringBacktick, Pop(1)},
},
"optional": {
// Docs syntax for showing optional parts with [ ]
{`\[`, Punctuation, Push("optional")},
Include("name_constants"),
{`\|`, Punctuation, nil},
{`[^\[\]\|]+`, String, nil},
{`\]`, Punctuation, Pop(1)},
},
"heredoc": {
{`(<<([a-zA-Z0-9_-]+))(\n(.*|\n)*)(\s*)(\2)`, ByGroups(StringHeredoc, nil, String, String, String, StringHeredoc), nil},
},
"name_constants": {
{`\b(most_recently_modified|largest_size|smallest_size|first_exist|internal|disable_redirects|ignore_loaded_certs|disable_certs|private_ranges|first|last|before|after|on|off)\b(\||(?=\]|\s|$))`, ByGroups(NameConstant, Punctuation), nil},
},
"placeholder": {
// Placeholder with dots, colon for default value, brackets for args[0:]
{`\{[\w+.\[\]\:\$-]+\}`, StringEscape, nil},
// Handle opening brackets with no matching closing one
{`\{[^\}\s]*\b`, String, nil},
},
"base": {
{caddyfileCommentRegexp, CommentSingle, nil},
{`\[\<matcher\>\]`, NameDecorator, nil},
Include("name_constants"),
Include("heredoc"),
{`(https?://)?([a-z0-9.-]+)(:)([0-9]+)([^\s]*)`, ByGroups(Name, Name, Punctuation, NumberInteger, Name), nil},
{`\[`, Punctuation, Push("optional")},
{"`", StringBacktick, Push("backticks")},
{`"`, StringDouble, Push("double_quotes")},
Include("placeholder"),
{`[a-z-]+/[a-z-+]+`, String, nil},
{`[0-9]+([smhdk]|ns|us|µs|ms)?\b`, NumberInteger, nil},
{`[^\s\n#\{]+`, String, nil},
{`/[^\s#]*`, Name, nil},
{`\s+`, Text, nil},
},
}
}
// Caddyfile lexer.
var Caddyfile = Register(MustNewLexer(
&Config{
Name: "Caddyfile",
Aliases: []string{"caddyfile", "caddy"},
Filenames: []string{"Caddyfile*"},
MimeTypes: []string{},
},
caddyfileRules,
))
func caddyfileRules() Rules {
return Rules{
"root": {
{caddyfileCommentRegexp, CommentSingle, nil},
// Global options block
{`^\s*(\{)\s*$`, ByGroups(Punctuation), Push("globals")},
// Top level import
{`(import)(\s+)([^\s]+)`, ByGroups(Keyword, Text, NameVariableMagic), nil},
// Snippets
{`(&?\([^\s#]+\))(\s*)(\{)`, ByGroups(NameVariableAnonymous, Text, Punctuation), Push("snippet")},
// Site label
{`[^#{(\s,]+`, GenericHeading, Push("label")},
// Site label with placeholder
{`\{[\w+.\[\]\:\$-]+\}`, StringEscape, Push("label")},
{`\s+`, Text, nil},
},
"globals": {
{`\}`, Punctuation, Pop(1)},
// Global options are parsed as subdirectives (no matcher)
{`[^\s#]+`, Keyword, Push("subdirective")},
Include("base"),
},
"snippet": {
{`\}`, Punctuation, Pop(1)},
Include("site_body"),
// Any other directive
{`[^\s#]+`, Keyword, Push("directive")},
Include("base"),
},
"label": {
// Allow multiple labels, comma separated, newlines after
// a comma means another label is coming
{`,\s*\n?`, Text, nil},
{` `, Text, nil},
// Site label with placeholder
Include("placeholder"),
// Site label
{`[^#{(\s,]+`, GenericHeading, nil},
// Comment after non-block label (hack because comments end in \n)
{`#.*\n`, CommentSingle, Push("site_block")},
// Note: if \n, we'll never pop out of the site_block, it's valid
{`\{(?=\s)|\n`, Punctuation, Push("site_block")},
},
"site_block": {
{`\}`, Punctuation, Pop(2)},
Include("site_block_common"),
},
}.Merge(caddyfileCommonRules())
}
// Caddyfile directive-only lexer.
var CaddyfileDirectives = Register(MustNewLexer(
&Config{
Name: "Caddyfile Directives",
Aliases: []string{"caddyfile-directives", "caddyfile-d", "caddy-d"},
Filenames: []string{},
MimeTypes: []string{},
},
caddyfileDirectivesRules,
))
func caddyfileDirectivesRules() Rules {
return Rules{
// Same as "site_block" in Caddyfile
"root": {
Include("site_block_common"),
},
}.Merge(caddyfileCommonRules())
}

View file

@ -0,0 +1,243 @@
package lexers
import (
. "github.com/alecthomas/chroma/v2" // nolint
)
var (
clBuiltinFunctions = []string{
"<", "<=", "=", ">", ">=", "-", "/", "/=", "*", "+", "1-", "1+",
"abort", "abs", "acons", "acos", "acosh", "add-method", "adjoin",
"adjustable-array-p", "adjust-array", "allocate-instance",
"alpha-char-p", "alphanumericp", "append", "apply", "apropos",
"apropos-list", "aref", "arithmetic-error-operands",
"arithmetic-error-operation", "array-dimension", "array-dimensions",
"array-displacement", "array-element-type", "array-has-fill-pointer-p",
"array-in-bounds-p", "arrayp", "array-rank", "array-row-major-index",
"array-total-size", "ash", "asin", "asinh", "assoc", "assoc-if",
"assoc-if-not", "atan", "atanh", "atom", "bit", "bit-and", "bit-andc1",
"bit-andc2", "bit-eqv", "bit-ior", "bit-nand", "bit-nor", "bit-not",
"bit-orc1", "bit-orc2", "bit-vector-p", "bit-xor", "boole",
"both-case-p", "boundp", "break", "broadcast-stream-streams",
"butlast", "byte", "byte-position", "byte-size", "caaaar", "caaadr",
"caaar", "caadar", "caaddr", "caadr", "caar", "cadaar", "cadadr",
"cadar", "caddar", "cadddr", "caddr", "cadr", "call-next-method", "car",
"cdaaar", "cdaadr", "cdaar", "cdadar", "cdaddr", "cdadr", "cdar",
"cddaar", "cddadr", "cddar", "cdddar", "cddddr", "cdddr", "cddr", "cdr",
"ceiling", "cell-error-name", "cerror", "change-class", "char", "char<",
"char<=", "char=", "char>", "char>=", "char/=", "character",
"characterp", "char-code", "char-downcase", "char-equal",
"char-greaterp", "char-int", "char-lessp", "char-name",
"char-not-equal", "char-not-greaterp", "char-not-lessp", "char-upcase",
"cis", "class-name", "class-of", "clear-input", "clear-output",
"close", "clrhash", "code-char", "coerce", "compile",
"compiled-function-p", "compile-file", "compile-file-pathname",
"compiler-macro-function", "complement", "complex", "complexp",
"compute-applicable-methods", "compute-restarts", "concatenate",
"concatenated-stream-streams", "conjugate", "cons", "consp",
"constantly", "constantp", "continue", "copy-alist", "copy-list",
"copy-pprint-dispatch", "copy-readtable", "copy-seq", "copy-structure",
"copy-symbol", "copy-tree", "cos", "cosh", "count", "count-if",
"count-if-not", "decode-float", "decode-universal-time", "delete",
"delete-duplicates", "delete-file", "delete-if", "delete-if-not",
"delete-package", "denominator", "deposit-field", "describe",
"describe-object", "digit-char", "digit-char-p", "directory",
"directory-namestring", "disassemble", "documentation", "dpb",
"dribble", "echo-stream-input-stream", "echo-stream-output-stream",
"ed", "eighth", "elt", "encode-universal-time", "endp",
"enough-namestring", "ensure-directories-exist",
"ensure-generic-function", "eq", "eql", "equal", "equalp", "error",
"eval", "evenp", "every", "exp", "export", "expt", "fboundp",
"fceiling", "fdefinition", "ffloor", "fifth", "file-author",
"file-error-pathname", "file-length", "file-namestring",
"file-position", "file-string-length", "file-write-date",
"fill", "fill-pointer", "find", "find-all-symbols", "find-class",
"find-if", "find-if-not", "find-method", "find-package", "find-restart",
"find-symbol", "finish-output", "first", "float", "float-digits",
"floatp", "float-precision", "float-radix", "float-sign", "floor",
"fmakunbound", "force-output", "format", "fourth", "fresh-line",
"fround", "ftruncate", "funcall", "function-keywords",
"function-lambda-expression", "functionp", "gcd", "gensym", "gentemp",
"get", "get-decoded-time", "get-dispatch-macro-character", "getf",
"gethash", "get-internal-real-time", "get-internal-run-time",
"get-macro-character", "get-output-stream-string", "get-properties",
"get-setf-expansion", "get-universal-time", "graphic-char-p",
"hash-table-count", "hash-table-p", "hash-table-rehash-size",
"hash-table-rehash-threshold", "hash-table-size", "hash-table-test",
"host-namestring", "identity", "imagpart", "import",
"initialize-instance", "input-stream-p", "inspect",
"integer-decode-float", "integer-length", "integerp",
"interactive-stream-p", "intern", "intersection",
"invalid-method-error", "invoke-debugger", "invoke-restart",
"invoke-restart-interactively", "isqrt", "keywordp", "last", "lcm",
"ldb", "ldb-test", "ldiff", "length", "lisp-implementation-type",
"lisp-implementation-version", "list", "list*", "list-all-packages",
"listen", "list-length", "listp", "load",
"load-logical-pathname-translations", "log", "logand", "logandc1",
"logandc2", "logbitp", "logcount", "logeqv", "logical-pathname",
"logical-pathname-translations", "logior", "lognand", "lognor",
"lognot", "logorc1", "logorc2", "logtest", "logxor", "long-site-name",
"lower-case-p", "machine-instance", "machine-type", "machine-version",
"macroexpand", "macroexpand-1", "macro-function", "make-array",
"make-broadcast-stream", "make-concatenated-stream", "make-condition",
"make-dispatch-macro-character", "make-echo-stream", "make-hash-table",
"make-instance", "make-instances-obsolete", "make-list",
"make-load-form", "make-load-form-saving-slots", "make-package",
"make-pathname", "make-random-state", "make-sequence", "make-string",
"make-string-input-stream", "make-string-output-stream", "make-symbol",
"make-synonym-stream", "make-two-way-stream", "makunbound", "map",
"mapc", "mapcan", "mapcar", "mapcon", "maphash", "map-into", "mapl",
"maplist", "mask-field", "max", "member", "member-if", "member-if-not",
"merge", "merge-pathnames", "method-combination-error",
"method-qualifiers", "min", "minusp", "mismatch", "mod",
"muffle-warning", "name-char", "namestring", "nbutlast", "nconc",
"next-method-p", "nintersection", "ninth", "no-applicable-method",
"no-next-method", "not", "notany", "notevery", "nreconc", "nreverse",
"nset-difference", "nset-exclusive-or", "nstring-capitalize",
"nstring-downcase", "nstring-upcase", "nsublis", "nsubst", "nsubst-if",
"nsubst-if-not", "nsubstitute", "nsubstitute-if", "nsubstitute-if-not",
"nth", "nthcdr", "null", "numberp", "numerator", "nunion", "oddp",
"open", "open-stream-p", "output-stream-p", "package-error-package",
"package-name", "package-nicknames", "packagep",
"package-shadowing-symbols", "package-used-by-list", "package-use-list",
"pairlis", "parse-integer", "parse-namestring", "pathname",
"pathname-device", "pathname-directory", "pathname-host",
"pathname-match-p", "pathname-name", "pathnamep", "pathname-type",
"pathname-version", "peek-char", "phase", "plusp", "position",
"position-if", "position-if-not", "pprint", "pprint-dispatch",
"pprint-fill", "pprint-indent", "pprint-linear", "pprint-newline",
"pprint-tab", "pprint-tabular", "prin1", "prin1-to-string", "princ",
"princ-to-string", "print", "print-object", "probe-file", "proclaim",
"provide", "random", "random-state-p", "rassoc", "rassoc-if",
"rassoc-if-not", "rational", "rationalize", "rationalp", "read",
"read-byte", "read-char", "read-char-no-hang", "read-delimited-list",
"read-from-string", "read-line", "read-preserving-whitespace",
"read-sequence", "readtable-case", "readtablep", "realp", "realpart",
"reduce", "reinitialize-instance", "rem", "remhash", "remove",
"remove-duplicates", "remove-if", "remove-if-not", "remove-method",
"remprop", "rename-file", "rename-package", "replace", "require",
"rest", "restart-name", "revappend", "reverse", "room", "round",
"row-major-aref", "rplaca", "rplacd", "sbit", "scale-float", "schar",
"search", "second", "set", "set-difference",
"set-dispatch-macro-character", "set-exclusive-or",
"set-macro-character", "set-pprint-dispatch", "set-syntax-from-char",
"seventh", "shadow", "shadowing-import", "shared-initialize",
"short-site-name", "signal", "signum", "simple-bit-vector-p",
"simple-condition-format-arguments", "simple-condition-format-control",
"simple-string-p", "simple-vector-p", "sin", "sinh", "sixth", "sleep",
"slot-boundp", "slot-exists-p", "slot-makunbound", "slot-missing",
"slot-unbound", "slot-value", "software-type", "software-version",
"some", "sort", "special-operator-p", "sqrt", "stable-sort",
"standard-char-p", "store-value", "stream-element-type",
"stream-error-stream", "stream-external-format", "streamp", "string",
"string<", "string<=", "string=", "string>", "string>=", "string/=",
"string-capitalize", "string-downcase", "string-equal",
"string-greaterp", "string-left-trim", "string-lessp",
"string-not-equal", "string-not-greaterp", "string-not-lessp",
"stringp", "string-right-trim", "string-trim", "string-upcase",
"sublis", "subseq", "subsetp", "subst", "subst-if", "subst-if-not",
"substitute", "substitute-if", "substitute-if-not", "subtypep", "svref",
"sxhash", "symbol-function", "symbol-name", "symbolp", "symbol-package",
"symbol-plist", "symbol-value", "synonym-stream-symbol", "syntax:",
"tailp", "tan", "tanh", "tenth", "terpri", "third",
"translate-logical-pathname", "translate-pathname", "tree-equal",
"truename", "truncate", "two-way-stream-input-stream",
"two-way-stream-output-stream", "type-error-datum",
"type-error-expected-type", "type-of", "typep", "unbound-slot-instance",
"unexport", "unintern", "union", "unread-char", "unuse-package",
"update-instance-for-different-class",
"update-instance-for-redefined-class", "upgraded-array-element-type",
"upgraded-complex-part-type", "upper-case-p", "use-package",
"user-homedir-pathname", "use-value", "values", "values-list", "vector",
"vectorp", "vector-pop", "vector-push", "vector-push-extend", "warn",
"wild-pathname-p", "write", "write-byte", "write-char", "write-line",
"write-sequence", "write-string", "write-to-string", "yes-or-no-p",
"y-or-n-p", "zerop",
}
clSpecialForms = []string{
"block", "catch", "declare", "eval-when", "flet", "function", "go", "if",
"labels", "lambda", "let", "let*", "load-time-value", "locally", "macrolet",
"multiple-value-call", "multiple-value-prog1", "progn", "progv", "quote",
"return-from", "setq", "symbol-macrolet", "tagbody", "the", "throw",
"unwind-protect",
}
clMacros = []string{
"and", "assert", "call-method", "case", "ccase", "check-type", "cond",
"ctypecase", "decf", "declaim", "defclass", "defconstant", "defgeneric",
"define-compiler-macro", "define-condition", "define-method-combination",
"define-modify-macro", "define-setf-expander", "define-symbol-macro",
"defmacro", "defmethod", "defpackage", "defparameter", "defsetf",
"defstruct", "deftype", "defun", "defvar", "destructuring-bind", "do",
"do*", "do-all-symbols", "do-external-symbols", "dolist", "do-symbols",
"dotimes", "ecase", "etypecase", "formatter", "handler-bind",
"handler-case", "ignore-errors", "incf", "in-package", "lambda", "loop",
"loop-finish", "make-method", "multiple-value-bind", "multiple-value-list",
"multiple-value-setq", "nth-value", "or", "pop",
"pprint-exit-if-list-exhausted", "pprint-logical-block", "pprint-pop",
"print-unreadable-object", "prog", "prog*", "prog1", "prog2", "psetf",
"psetq", "push", "pushnew", "remf", "restart-bind", "restart-case",
"return", "rotatef", "setf", "shiftf", "step", "time", "trace", "typecase",
"unless", "untrace", "when", "with-accessors", "with-compilation-unit",
"with-condition-restarts", "with-hash-table-iterator",
"with-input-from-string", "with-open-file", "with-open-stream",
"with-output-to-string", "with-package-iterator", "with-simple-restart",
"with-slots", "with-standard-io-syntax",
}
clLambdaListKeywords = []string{
"&allow-other-keys", "&aux", "&body", "&environment", "&key", "&optional",
"&rest", "&whole",
}
clDeclarations = []string{
"dynamic-extent", "ignore", "optimize", "ftype", "inline", "special",
"ignorable", "notinline", "type",
}
clBuiltinTypes = []string{
"atom", "boolean", "base-char", "base-string", "bignum", "bit",
"compiled-function", "extended-char", "fixnum", "keyword", "nil",
"signed-byte", "short-float", "single-float", "double-float", "long-float",
"simple-array", "simple-base-string", "simple-bit-vector", "simple-string",
"simple-vector", "standard-char", "unsigned-byte",
// Condition Types
"arithmetic-error", "cell-error", "condition", "control-error",
"division-by-zero", "end-of-file", "error", "file-error",
"floating-point-inexact", "floating-point-overflow",
"floating-point-underflow", "floating-point-invalid-operation",
"parse-error", "package-error", "print-not-readable", "program-error",
"reader-error", "serious-condition", "simple-condition", "simple-error",
"simple-type-error", "simple-warning", "stream-error", "storage-condition",
"style-warning", "type-error", "unbound-variable", "unbound-slot",
"undefined-function", "warning",
}
clBuiltinClasses = []string{
"array", "broadcast-stream", "bit-vector", "built-in-class", "character",
"class", "complex", "concatenated-stream", "cons", "echo-stream",
"file-stream", "float", "function", "generic-function", "hash-table",
"integer", "list", "logical-pathname", "method-combination", "method",
"null", "number", "package", "pathname", "ratio", "rational", "readtable",
"real", "random-state", "restart", "sequence", "standard-class",
"standard-generic-function", "standard-method", "standard-object",
"string-stream", "stream", "string", "structure-class", "structure-object",
"symbol", "synonym-stream", "t", "two-way-stream", "vector",
}
)
// Common Lisp lexer.
var CommonLisp = Register(TypeRemappingLexer(MustNewXMLLexer(
embedded,
"embedded/common_lisp.xml",
), TypeMapping{
{NameVariable, NameFunction, clBuiltinFunctions},
{NameVariable, Keyword, clSpecialForms},
{NameVariable, NameBuiltin, clMacros},
{NameVariable, Keyword, clLambdaListKeywords},
{NameVariable, Keyword, clDeclarations},
{NameVariable, KeywordType, clBuiltinTypes},
{NameVariable, NameClass, clBuiltinClasses},
}))

View file

@ -0,0 +1,17 @@
package lexers
import (
"regexp"
)
// TODO(moorereason): can this be factored away?
var zoneAnalyserRe = regexp.MustCompile(`(?m)^@\s+IN\s+SOA\s+`)
func init() { // nolint: gochecknoinits
Get("dns").SetAnalyser(func(text string) float32 {
if zoneAnalyserRe.FindString(text) != "" {
return 1.0
}
return 0.0
})
}

View file

@ -0,0 +1,533 @@
package lexers
import (
. "github.com/alecthomas/chroma/v2" // nolint
)
var (
emacsMacros = []string{
"atomic-change-group", "case", "block", "cl-block", "cl-callf", "cl-callf2",
"cl-case", "cl-decf", "cl-declaim", "cl-declare",
"cl-define-compiler-macro", "cl-defmacro", "cl-defstruct",
"cl-defsubst", "cl-deftype", "cl-defun", "cl-destructuring-bind",
"cl-do", "cl-do*", "cl-do-all-symbols", "cl-do-symbols", "cl-dolist",
"cl-dotimes", "cl-ecase", "cl-etypecase", "eval-when", "cl-eval-when", "cl-flet",
"cl-flet*", "cl-function", "cl-incf", "cl-labels", "cl-letf",
"cl-letf*", "cl-load-time-value", "cl-locally", "cl-loop",
"cl-macrolet", "cl-multiple-value-bind", "cl-multiple-value-setq",
"cl-progv", "cl-psetf", "cl-psetq", "cl-pushnew", "cl-remf",
"cl-return", "cl-return-from", "cl-rotatef", "cl-shiftf",
"cl-symbol-macrolet", "cl-tagbody", "cl-the", "cl-typecase",
"combine-after-change-calls", "condition-case-unless-debug", "decf",
"declaim", "declare", "declare-function", "def-edebug-spec",
"defadvice", "defclass", "defcustom", "defface", "defgeneric",
"defgroup", "define-advice", "define-alternatives",
"define-compiler-macro", "define-derived-mode", "define-generic-mode",
"define-global-minor-mode", "define-globalized-minor-mode",
"define-minor-mode", "define-modify-macro",
"define-obsolete-face-alias", "define-obsolete-function-alias",
"define-obsolete-variable-alias", "define-setf-expander",
"define-skeleton", "defmacro", "defmethod", "defsetf", "defstruct",
"defsubst", "deftheme", "deftype", "defun", "defvar-local",
"delay-mode-hooks", "destructuring-bind", "do", "do*",
"do-all-symbols", "do-symbols", "dolist", "dont-compile", "dotimes",
"dotimes-with-progress-reporter", "ecase", "ert-deftest", "etypecase",
"eval-and-compile", "eval-when-compile", "flet", "ignore-errors",
"incf", "labels", "lambda", "letrec", "lexical-let", "lexical-let*",
"loop", "multiple-value-bind", "multiple-value-setq", "noreturn",
"oref", "oref-default", "oset", "oset-default", "pcase",
"pcase-defmacro", "pcase-dolist", "pcase-exhaustive", "pcase-let",
"pcase-let*", "pop", "psetf", "psetq", "push", "pushnew", "remf",
"return", "rotatef", "rx", "save-match-data", "save-selected-window",
"save-window-excursion", "setf", "setq-local", "shiftf",
"track-mouse", "typecase", "unless", "use-package", "when",
"while-no-input", "with-case-table", "with-category-table",
"with-coding-priority", "with-current-buffer", "with-demoted-errors",
"with-eval-after-load", "with-file-modes", "with-local-quit",
"with-output-to-string", "with-output-to-temp-buffer",
"with-parsed-tramp-file-name", "with-selected-frame",
"with-selected-window", "with-silent-modifications", "with-slots",
"with-syntax-table", "with-temp-buffer", "with-temp-file",
"with-temp-message", "with-timeout", "with-tramp-connection-property",
"with-tramp-file-property", "with-tramp-progress-reporter",
"with-wrapper-hook", "load-time-value", "locally", "macrolet", "progv",
"return-from",
}
emacsSpecialForms = []string{
"and", "catch", "cond", "condition-case", "defconst", "defvar",
"function", "if", "interactive", "let", "let*", "or", "prog1",
"prog2", "progn", "quote", "save-current-buffer", "save-excursion",
"save-restriction", "setq", "setq-default", "subr-arity",
"unwind-protect", "while",
}
emacsBuiltinFunction = []string{
"%", "*", "+", "-", "/", "/=", "1+", "1-", "<", "<=", "=", ">", ">=",
"Snarf-documentation", "abort-recursive-edit", "abs",
"accept-process-output", "access-file", "accessible-keymaps", "acos",
"active-minibuffer-window", "add-face-text-property",
"add-name-to-file", "add-text-properties", "all-completions",
"append", "apply", "apropos-internal", "aref", "arrayp", "aset",
"ash", "asin", "assoc", "assoc-string", "assq", "atan", "atom",
"autoload", "autoload-do-load", "backtrace", "backtrace--locals",
"backtrace-debug", "backtrace-eval", "backtrace-frame",
"backward-char", "backward-prefix-chars", "barf-if-buffer-read-only",
"base64-decode-region", "base64-decode-string",
"base64-encode-region", "base64-encode-string", "beginning-of-line",
"bidi-find-overridden-directionality", "bidi-resolved-levels",
"bitmap-spec-p", "bobp", "bolp", "bool-vector",
"bool-vector-count-consecutive", "bool-vector-count-population",
"bool-vector-exclusive-or", "bool-vector-intersection",
"bool-vector-not", "bool-vector-p", "bool-vector-set-difference",
"bool-vector-subsetp", "bool-vector-union", "boundp",
"buffer-base-buffer", "buffer-chars-modified-tick",
"buffer-enable-undo", "buffer-file-name", "buffer-has-markers-at",
"buffer-list", "buffer-live-p", "buffer-local-value",
"buffer-local-variables", "buffer-modified-p", "buffer-modified-tick",
"buffer-name", "buffer-size", "buffer-string", "buffer-substring",
"buffer-substring-no-properties", "buffer-swap-text", "bufferp",
"bury-buffer-internal", "byte-code", "byte-code-function-p",
"byte-to-position", "byte-to-string", "byteorder",
"call-interactively", "call-last-kbd-macro", "call-process",
"call-process-region", "cancel-kbd-macro-events", "capitalize",
"capitalize-region", "capitalize-word", "car", "car-less-than-car",
"car-safe", "case-table-p", "category-docstring",
"category-set-mnemonics", "category-table", "category-table-p",
"ccl-execute", "ccl-execute-on-string", "ccl-program-p", "cdr",
"cdr-safe", "ceiling", "char-after", "char-before",
"char-category-set", "char-charset", "char-equal", "char-or-string-p",
"char-resolve-modifiers", "char-syntax", "char-table-extra-slot",
"char-table-p", "char-table-parent", "char-table-range",
"char-table-subtype", "char-to-string", "char-width", "characterp",
"charset-after", "charset-id-internal", "charset-plist",
"charset-priority-list", "charsetp", "check-coding-system",
"check-coding-systems-region", "clear-buffer-auto-save-failure",
"clear-charset-maps", "clear-face-cache", "clear-font-cache",
"clear-image-cache", "clear-string", "clear-this-command-keys",
"close-font", "clrhash", "coding-system-aliases",
"coding-system-base", "coding-system-eol-type", "coding-system-p",
"coding-system-plist", "coding-system-priority-list",
"coding-system-put", "color-distance", "color-gray-p",
"color-supported-p", "combine-after-change-execute",
"command-error-default-function", "command-remapping", "commandp",
"compare-buffer-substrings", "compare-strings",
"compare-window-configurations", "completing-read",
"compose-region-internal", "compose-string-internal",
"composition-get-gstring", "compute-motion", "concat", "cons",
"consp", "constrain-to-field", "continue-process",
"controlling-tty-p", "coordinates-in-window-p", "copy-alist",
"copy-category-table", "copy-file", "copy-hash-table", "copy-keymap",
"copy-marker", "copy-sequence", "copy-syntax-table", "copysign",
"cos", "current-active-maps", "current-bidi-paragraph-direction",
"current-buffer", "current-case-table", "current-column",
"current-global-map", "current-idle-time", "current-indentation",
"current-input-mode", "current-local-map", "current-message",
"current-minor-mode-maps", "current-time", "current-time-string",
"current-time-zone", "current-window-configuration",
"cygwin-convert-file-name-from-windows",
"cygwin-convert-file-name-to-windows", "daemon-initialized",
"daemonp", "dbus--init-bus", "dbus-get-unique-name",
"dbus-message-internal", "debug-timer-check", "declare-equiv-charset",
"decode-big5-char", "decode-char", "decode-coding-region",
"decode-coding-string", "decode-sjis-char", "decode-time",
"default-boundp", "default-file-modes", "default-printer-name",
"default-toplevel-value", "default-value", "define-category",
"define-charset-alias", "define-charset-internal",
"define-coding-system-alias", "define-coding-system-internal",
"define-fringe-bitmap", "define-hash-table-test", "define-key",
"define-prefix-command", "delete",
"delete-all-overlays", "delete-and-extract-region", "delete-char",
"delete-directory-internal", "delete-field", "delete-file",
"delete-frame", "delete-other-windows-internal", "delete-overlay",
"delete-process", "delete-region", "delete-terminal",
"delete-window-internal", "delq", "describe-buffer-bindings",
"describe-vector", "destroy-fringe-bitmap", "detect-coding-region",
"detect-coding-string", "ding", "directory-file-name",
"directory-files", "directory-files-and-attributes", "discard-input",
"display-supports-face-attributes-p", "do-auto-save", "documentation",
"documentation-property", "downcase", "downcase-region",
"downcase-word", "draw-string", "dump-colors", "dump-emacs",
"dump-face", "dump-frame-glyph-matrix", "dump-glyph-matrix",
"dump-glyph-row", "dump-redisplay-history", "dump-tool-bar-row",
"elt", "emacs-pid", "encode-big5-char", "encode-char",
"encode-coding-region", "encode-coding-string", "encode-sjis-char",
"encode-time", "end-kbd-macro", "end-of-line", "eobp", "eolp", "eq",
"eql", "equal", "equal-including-properties", "erase-buffer",
"error-message-string", "eval", "eval-buffer", "eval-region",
"event-convert-list", "execute-kbd-macro", "exit-recursive-edit",
"exp", "expand-file-name", "expt", "external-debugging-output",
"face-attribute-relative-p", "face-attributes-as-vector", "face-font",
"fboundp", "fceiling", "fetch-bytecode", "ffloor",
"field-beginning", "field-end", "field-string",
"field-string-no-properties", "file-accessible-directory-p",
"file-acl", "file-attributes", "file-attributes-lessp",
"file-directory-p", "file-executable-p", "file-exists-p",
"file-locked-p", "file-modes", "file-name-absolute-p",
"file-name-all-completions", "file-name-as-directory",
"file-name-completion", "file-name-directory",
"file-name-nondirectory", "file-newer-than-file-p", "file-readable-p",
"file-regular-p", "file-selinux-context", "file-symlink-p",
"file-system-info", "file-system-info", "file-writable-p",
"fillarray", "find-charset-region", "find-charset-string",
"find-coding-systems-region-internal", "find-composition-internal",
"find-file-name-handler", "find-font", "find-operation-coding-system",
"float", "float-time", "floatp", "floor", "fmakunbound",
"following-char", "font-at", "font-drive-otf", "font-face-attributes",
"font-family-list", "font-get", "font-get-glyphs",
"font-get-system-font", "font-get-system-normal-font", "font-info",
"font-match-p", "font-otf-alternates", "font-put",
"font-shape-gstring", "font-spec", "font-variation-glyphs",
"font-xlfd-name", "fontp", "fontset-font", "fontset-info",
"fontset-list", "fontset-list-all", "force-mode-line-update",
"force-window-update", "format", "format-mode-line",
"format-network-address", "format-time-string", "forward-char",
"forward-comment", "forward-line", "forward-word",
"frame-border-width", "frame-bottom-divider-width",
"frame-can-run-window-configuration-change-hook", "frame-char-height",
"frame-char-width", "frame-face-alist", "frame-first-window",
"frame-focus", "frame-font-cache", "frame-fringe-width", "frame-list",
"frame-live-p", "frame-or-buffer-changed-p", "frame-parameter",
"frame-parameters", "frame-pixel-height", "frame-pixel-width",
"frame-pointer-visible-p", "frame-right-divider-width",
"frame-root-window", "frame-scroll-bar-height",
"frame-scroll-bar-width", "frame-selected-window", "frame-terminal",
"frame-text-cols", "frame-text-height", "frame-text-lines",
"frame-text-width", "frame-total-cols", "frame-total-lines",
"frame-visible-p", "framep", "frexp", "fringe-bitmaps-at-pos",
"fround", "fset", "ftruncate", "funcall", "funcall-interactively",
"function-equal", "functionp", "gap-position", "gap-size",
"garbage-collect", "gc-status", "generate-new-buffer-name", "get",
"get-buffer", "get-buffer-create", "get-buffer-process",
"get-buffer-window", "get-byte", "get-char-property",
"get-char-property-and-overlay", "get-file-buffer", "get-file-char",
"get-internal-run-time", "get-load-suffixes", "get-pos-property",
"get-process", "get-screen-color", "get-text-property",
"get-unicode-property-internal", "get-unused-category",
"get-unused-iso-final-char", "getenv-internal", "gethash",
"gfile-add-watch", "gfile-rm-watch", "global-key-binding",
"gnutls-available-p", "gnutls-boot", "gnutls-bye", "gnutls-deinit",
"gnutls-error-fatalp", "gnutls-error-string", "gnutls-errorp",
"gnutls-get-initstage", "gnutls-peer-status",
"gnutls-peer-status-warning-describe", "goto-char", "gpm-mouse-start",
"gpm-mouse-stop", "group-gid", "group-real-gid",
"handle-save-session", "handle-switch-frame", "hash-table-count",
"hash-table-p", "hash-table-rehash-size",
"hash-table-rehash-threshold", "hash-table-size", "hash-table-test",
"hash-table-weakness", "iconify-frame", "identity", "image-flush",
"image-mask-p", "image-metadata", "image-size", "imagemagick-types",
"imagep", "indent-to", "indirect-function", "indirect-variable",
"init-image-library", "inotify-add-watch", "inotify-rm-watch",
"input-pending-p", "insert", "insert-and-inherit",
"insert-before-markers", "insert-before-markers-and-inherit",
"insert-buffer-substring", "insert-byte", "insert-char",
"insert-file-contents", "insert-startup-screen", "int86",
"integer-or-marker-p", "integerp", "interactive-form", "intern",
"intern-soft", "internal--track-mouse", "internal-char-font",
"internal-complete-buffer", "internal-copy-lisp-face",
"internal-default-process-filter",
"internal-default-process-sentinel", "internal-describe-syntax-value",
"internal-event-symbol-parse-modifiers",
"internal-face-x-get-resource", "internal-get-lisp-face-attribute",
"internal-lisp-face-attribute-values", "internal-lisp-face-empty-p",
"internal-lisp-face-equal-p", "internal-lisp-face-p",
"internal-make-lisp-face", "internal-make-var-non-special",
"internal-merge-in-global-face",
"internal-set-alternative-font-family-alist",
"internal-set-alternative-font-registry-alist",
"internal-set-font-selection-order",
"internal-set-lisp-face-attribute",
"internal-set-lisp-face-attribute-from-resource",
"internal-show-cursor", "internal-show-cursor-p", "interrupt-process",
"invisible-p", "invocation-directory", "invocation-name", "isnan",
"iso-charset", "key-binding", "key-description",
"keyboard-coding-system", "keymap-parent", "keymap-prompt", "keymapp",
"keywordp", "kill-all-local-variables", "kill-buffer", "kill-emacs",
"kill-local-variable", "kill-process", "last-nonminibuffer-frame",
"lax-plist-get", "lax-plist-put", "ldexp", "length",
"libxml-parse-html-region", "libxml-parse-xml-region",
"line-beginning-position", "line-end-position", "line-pixel-height",
"list", "list-fonts", "list-system-processes", "listp", "load",
"load-average", "local-key-binding", "local-variable-if-set-p",
"local-variable-p", "locale-info", "locate-file-internal",
"lock-buffer", "log", "logand", "logb", "logior", "lognot", "logxor",
"looking-at", "lookup-image", "lookup-image-map", "lookup-key",
"lower-frame", "lsh", "macroexpand", "make-bool-vector",
"make-byte-code", "make-category-set", "make-category-table",
"make-char", "make-char-table", "make-directory-internal",
"make-frame-invisible", "make-frame-visible", "make-hash-table",
"make-indirect-buffer", "make-keymap", "make-list",
"make-local-variable", "make-marker", "make-network-process",
"make-overlay", "make-serial-process", "make-sparse-keymap",
"make-string", "make-symbol", "make-symbolic-link", "make-temp-name",
"make-terminal-frame", "make-variable-buffer-local",
"make-variable-frame-local", "make-vector", "makunbound",
"map-char-table", "map-charset-chars", "map-keymap",
"map-keymap-internal", "mapatoms", "mapc", "mapcar", "mapconcat",
"maphash", "mark-marker", "marker-buffer", "marker-insertion-type",
"marker-position", "markerp", "match-beginning", "match-data",
"match-end", "matching-paren", "max", "max-char", "md5", "member",
"memory-info", "memory-limit", "memory-use-counts", "memq", "memql",
"menu-bar-menu-at-x-y", "menu-or-popup-active-p",
"menu-or-popup-active-p", "merge-face-attribute", "message",
"message-box", "message-or-box", "min",
"minibuffer-completion-contents", "minibuffer-contents",
"minibuffer-contents-no-properties", "minibuffer-depth",
"minibuffer-prompt", "minibuffer-prompt-end",
"minibuffer-selected-window", "minibuffer-window", "minibufferp",
"minor-mode-key-binding", "mod", "modify-category-entry",
"modify-frame-parameters", "modify-syntax-entry",
"mouse-pixel-position", "mouse-position", "move-overlay",
"move-point-visually", "move-to-column", "move-to-window-line",
"msdos-downcase-filename", "msdos-long-file-names", "msdos-memget",
"msdos-memput", "msdos-mouse-disable", "msdos-mouse-enable",
"msdos-mouse-init", "msdos-mouse-p", "msdos-remember-default-colors",
"msdos-set-keyboard", "msdos-set-mouse-buttons",
"multibyte-char-to-unibyte", "multibyte-string-p", "narrow-to-region",
"natnump", "nconc", "network-interface-info",
"network-interface-list", "new-fontset", "newline-cache-check",
"next-char-property-change", "next-frame", "next-overlay-change",
"next-property-change", "next-read-file-uses-dialog-p",
"next-single-char-property-change", "next-single-property-change",
"next-window", "nlistp", "nreverse", "nth", "nthcdr", "null",
"number-or-marker-p", "number-to-string", "numberp",
"open-dribble-file", "open-font", "open-termscript",
"optimize-char-table", "other-buffer", "other-window-for-scrolling",
"overlay-buffer", "overlay-end", "overlay-get", "overlay-lists",
"overlay-properties", "overlay-put", "overlay-recenter",
"overlay-start", "overlayp", "overlays-at", "overlays-in",
"parse-partial-sexp", "play-sound-internal", "plist-get",
"plist-member", "plist-put", "point", "point-marker", "point-max",
"point-max-marker", "point-min", "point-min-marker",
"pos-visible-in-window-p", "position-bytes", "posix-looking-at",
"posix-search-backward", "posix-search-forward", "posix-string-match",
"posn-at-point", "posn-at-x-y", "preceding-char",
"prefix-numeric-value", "previous-char-property-change",
"previous-frame", "previous-overlay-change",
"previous-property-change", "previous-single-char-property-change",
"previous-single-property-change", "previous-window", "prin1",
"prin1-to-string", "princ", "print", "process-attributes",
"process-buffer", "process-coding-system", "process-command",
"process-connection", "process-contact", "process-datagram-address",
"process-exit-status", "process-filter", "process-filter-multibyte-p",
"process-id", "process-inherit-coding-system-flag", "process-list",
"process-mark", "process-name", "process-plist",
"process-query-on-exit-flag", "process-running-child-p",
"process-send-eof", "process-send-region", "process-send-string",
"process-sentinel", "process-status", "process-tty-name",
"process-type", "processp", "profiler-cpu-log",
"profiler-cpu-running-p", "profiler-cpu-start", "profiler-cpu-stop",
"profiler-memory-log", "profiler-memory-running-p",
"profiler-memory-start", "profiler-memory-stop", "propertize",
"purecopy", "put", "put-text-property",
"put-unicode-property-internal", "puthash", "query-font",
"query-fontset", "quit-process", "raise-frame", "random", "rassoc",
"rassq", "re-search-backward", "re-search-forward", "read",
"read-buffer", "read-char", "read-char-exclusive",
"read-coding-system", "read-command", "read-event",
"read-from-minibuffer", "read-from-string", "read-function",
"read-key-sequence", "read-key-sequence-vector",
"read-no-blanks-input", "read-non-nil-coding-system", "read-string",
"read-variable", "recent-auto-save-p", "recent-doskeys",
"recent-keys", "recenter", "recursion-depth", "recursive-edit",
"redirect-debugging-output", "redirect-frame-focus", "redisplay",
"redraw-display", "redraw-frame", "regexp-quote", "region-beginning",
"region-end", "register-ccl-program", "register-code-conversion-map",
"remhash", "remove-list-of-text-properties", "remove-text-properties",
"rename-buffer", "rename-file", "replace-match",
"reset-this-command-lengths", "resize-mini-window-internal",
"restore-buffer-modified-p", "resume-tty", "reverse", "round",
"run-hook-with-args", "run-hook-with-args-until-failure",
"run-hook-with-args-until-success", "run-hook-wrapped", "run-hooks",
"run-window-configuration-change-hook", "run-window-scroll-functions",
"safe-length", "scan-lists", "scan-sexps", "scroll-down",
"scroll-left", "scroll-other-window", "scroll-right", "scroll-up",
"search-backward", "search-forward", "secure-hash", "select-frame",
"select-window", "selected-frame", "selected-window",
"self-insert-command", "send-string-to-terminal", "sequencep",
"serial-process-configure", "set", "set-buffer",
"set-buffer-auto-saved", "set-buffer-major-mode",
"set-buffer-modified-p", "set-buffer-multibyte", "set-case-table",
"set-category-table", "set-char-table-extra-slot",
"set-char-table-parent", "set-char-table-range", "set-charset-plist",
"set-charset-priority", "set-coding-system-priority",
"set-cursor-size", "set-default", "set-default-file-modes",
"set-default-toplevel-value", "set-file-acl", "set-file-modes",
"set-file-selinux-context", "set-file-times", "set-fontset-font",
"set-frame-height", "set-frame-position", "set-frame-selected-window",
"set-frame-size", "set-frame-width", "set-fringe-bitmap-face",
"set-input-interrupt-mode", "set-input-meta-mode", "set-input-mode",
"set-keyboard-coding-system-internal", "set-keymap-parent",
"set-marker", "set-marker-insertion-type", "set-match-data",
"set-message-beep", "set-minibuffer-window",
"set-mouse-pixel-position", "set-mouse-position",
"set-network-process-option", "set-output-flow-control",
"set-process-buffer", "set-process-coding-system",
"set-process-datagram-address", "set-process-filter",
"set-process-filter-multibyte",
"set-process-inherit-coding-system-flag", "set-process-plist",
"set-process-query-on-exit-flag", "set-process-sentinel",
"set-process-window-size", "set-quit-char",
"set-safe-terminal-coding-system-internal", "set-screen-color",
"set-standard-case-table", "set-syntax-table",
"set-terminal-coding-system-internal", "set-terminal-local-value",
"set-terminal-parameter", "set-text-properties", "set-time-zone-rule",
"set-visited-file-modtime", "set-window-buffer",
"set-window-combination-limit", "set-window-configuration",
"set-window-dedicated-p", "set-window-display-table",
"set-window-fringes", "set-window-hscroll", "set-window-margins",
"set-window-new-normal", "set-window-new-pixel",
"set-window-new-total", "set-window-next-buffers",
"set-window-parameter", "set-window-point", "set-window-prev-buffers",
"set-window-redisplay-end-trigger", "set-window-scroll-bars",
"set-window-start", "set-window-vscroll", "setcar", "setcdr",
"setplist", "show-face-resources", "signal", "signal-process", "sin",
"single-key-description", "skip-chars-backward", "skip-chars-forward",
"skip-syntax-backward", "skip-syntax-forward", "sleep-for", "sort",
"sort-charsets", "special-variable-p", "split-char",
"split-window-internal", "sqrt", "standard-case-table",
"standard-category-table", "standard-syntax-table", "start-kbd-macro",
"start-process", "stop-process", "store-kbd-macro-event", "string",
"string-as-multibyte", "string-as-unibyte", "string-bytes",
"string-collate-equalp", "string-collate-lessp", "string-equal",
"string-lessp", "string-make-multibyte", "string-make-unibyte",
"string-match", "string-to-char", "string-to-multibyte",
"string-to-number", "string-to-syntax", "string-to-unibyte",
"string-width", "stringp", "subr-name", "subrp",
"subst-char-in-region", "substitute-command-keys",
"substitute-in-file-name", "substring", "substring-no-properties",
"suspend-emacs", "suspend-tty", "suspicious-object", "sxhash",
"symbol-function", "symbol-name", "symbol-plist", "symbol-value",
"symbolp", "syntax-table", "syntax-table-p", "system-groups",
"system-move-file-to-trash", "system-name", "system-users", "tan",
"terminal-coding-system", "terminal-list", "terminal-live-p",
"terminal-local-value", "terminal-name", "terminal-parameter",
"terminal-parameters", "terpri", "test-completion",
"text-char-description", "text-properties-at", "text-property-any",
"text-property-not-all", "this-command-keys",
"this-command-keys-vector", "this-single-command-keys",
"this-single-command-raw-keys", "time-add", "time-less-p",
"time-subtract", "tool-bar-get-system-style", "tool-bar-height",
"tool-bar-pixel-width", "top-level", "trace-redisplay",
"trace-to-stderr", "translate-region-internal", "transpose-regions",
"truncate", "try-completion", "tty-display-color-cells",
"tty-display-color-p", "tty-no-underline",
"tty-suppress-bold-inverse-default-colors", "tty-top-frame",
"tty-type", "type-of", "undo-boundary", "unencodable-char-position",
"unhandled-file-name-directory", "unibyte-char-to-multibyte",
"unibyte-string", "unicode-property-table-internal", "unify-charset",
"unintern", "unix-sync", "unlock-buffer", "upcase", "upcase-initials",
"upcase-initials-region", "upcase-region", "upcase-word",
"use-global-map", "use-local-map", "user-full-name",
"user-login-name", "user-real-login-name", "user-real-uid",
"user-uid", "variable-binding-locus", "vconcat", "vector",
"vector-or-char-table-p", "vectorp", "verify-visited-file-modtime",
"vertical-motion", "visible-frame-list", "visited-file-modtime",
"w16-get-clipboard-data", "w16-selection-exists-p",
"w16-set-clipboard-data", "w32-battery-status",
"w32-default-color-map", "w32-define-rgb-color",
"w32-display-monitor-attributes-list", "w32-frame-menu-bar-size",
"w32-frame-rect", "w32-get-clipboard-data",
"w32-get-codepage-charset", "w32-get-console-codepage",
"w32-get-console-output-codepage", "w32-get-current-locale-id",
"w32-get-default-locale-id", "w32-get-keyboard-layout",
"w32-get-locale-info", "w32-get-valid-codepages",
"w32-get-valid-keyboard-layouts", "w32-get-valid-locale-ids",
"w32-has-winsock", "w32-long-file-name", "w32-reconstruct-hot-key",
"w32-register-hot-key", "w32-registered-hot-keys",
"w32-selection-exists-p", "w32-send-sys-command",
"w32-set-clipboard-data", "w32-set-console-codepage",
"w32-set-console-output-codepage", "w32-set-current-locale",
"w32-set-keyboard-layout", "w32-set-process-priority",
"w32-shell-execute", "w32-short-file-name", "w32-toggle-lock-key",
"w32-unload-winsock", "w32-unregister-hot-key", "w32-window-exists-p",
"w32notify-add-watch", "w32notify-rm-watch",
"waiting-for-user-input-p", "where-is-internal", "widen",
"widget-apply", "widget-get", "widget-put",
"window-absolute-pixel-edges", "window-at", "window-body-height",
"window-body-width", "window-bottom-divider-width", "window-buffer",
"window-combination-limit", "window-configuration-frame",
"window-configuration-p", "window-dedicated-p",
"window-display-table", "window-edges", "window-end", "window-frame",
"window-fringes", "window-header-line-height", "window-hscroll",
"window-inside-absolute-pixel-edges", "window-inside-edges",
"window-inside-pixel-edges", "window-left-child",
"window-left-column", "window-line-height", "window-list",
"window-list-1", "window-live-p", "window-margins",
"window-minibuffer-p", "window-mode-line-height", "window-new-normal",
"window-new-pixel", "window-new-total", "window-next-buffers",
"window-next-sibling", "window-normal-size", "window-old-point",
"window-parameter", "window-parameters", "window-parent",
"window-pixel-edges", "window-pixel-height", "window-pixel-left",
"window-pixel-top", "window-pixel-width", "window-point",
"window-prev-buffers", "window-prev-sibling",
"window-redisplay-end-trigger", "window-resize-apply",
"window-resize-apply-total", "window-right-divider-width",
"window-scroll-bar-height", "window-scroll-bar-width",
"window-scroll-bars", "window-start", "window-system",
"window-text-height", "window-text-pixel-size", "window-text-width",
"window-top-child", "window-top-line", "window-total-height",
"window-total-width", "window-use-time", "window-valid-p",
"window-vscroll", "windowp", "write-char", "write-region",
"x-backspace-delete-keys-p", "x-change-window-property",
"x-change-window-property", "x-close-connection",
"x-close-connection", "x-create-frame", "x-create-frame",
"x-delete-window-property", "x-delete-window-property",
"x-disown-selection-internal", "x-display-backing-store",
"x-display-backing-store", "x-display-color-cells",
"x-display-color-cells", "x-display-grayscale-p",
"x-display-grayscale-p", "x-display-list", "x-display-list",
"x-display-mm-height", "x-display-mm-height", "x-display-mm-width",
"x-display-mm-width", "x-display-monitor-attributes-list",
"x-display-pixel-height", "x-display-pixel-height",
"x-display-pixel-width", "x-display-pixel-width", "x-display-planes",
"x-display-planes", "x-display-save-under", "x-display-save-under",
"x-display-screens", "x-display-screens", "x-display-visual-class",
"x-display-visual-class", "x-family-fonts", "x-file-dialog",
"x-file-dialog", "x-file-dialog", "x-focus-frame", "x-frame-geometry",
"x-frame-geometry", "x-get-atom-name", "x-get-resource",
"x-get-selection-internal", "x-hide-tip", "x-hide-tip",
"x-list-fonts", "x-load-color-file", "x-menu-bar-open-internal",
"x-menu-bar-open-internal", "x-open-connection", "x-open-connection",
"x-own-selection-internal", "x-parse-geometry", "x-popup-dialog",
"x-popup-menu", "x-register-dnd-atom", "x-select-font",
"x-select-font", "x-selection-exists-p", "x-selection-owner-p",
"x-send-client-message", "x-server-max-request-size",
"x-server-max-request-size", "x-server-vendor", "x-server-vendor",
"x-server-version", "x-server-version", "x-show-tip", "x-show-tip",
"x-synchronize", "x-synchronize", "x-uses-old-gtk-dialog",
"x-window-property", "x-window-property", "x-wm-set-size-hint",
"xw-color-defined-p", "xw-color-defined-p", "xw-color-values",
"xw-color-values", "xw-display-color-p", "xw-display-color-p",
"yes-or-no-p", "zlib-available-p", "zlib-decompress-region",
"forward-point",
}
emacsBuiltinFunctionHighlighted = []string{
"defvaralias", "provide", "require",
"with-no-warnings", "define-widget", "with-electric-help",
"throw", "defalias", "featurep",
}
emacsLambdaListKeywords = []string{
"&allow-other-keys", "&aux", "&body", "&environment", "&key", "&optional",
"&rest", "&whole",
}
emacsErrorKeywords = []string{
"cl-assert", "cl-check-type", "error", "signal",
"user-error", "warn",
}
)
// EmacsLisp lexer.
var EmacsLisp = Register(TypeRemappingLexer(MustNewXMLLexer(
embedded,
"embedded/emacslisp.xml",
), TypeMapping{
{NameVariable, NameFunction, emacsBuiltinFunction},
{NameVariable, NameBuiltin, emacsSpecialForms},
{NameVariable, NameException, emacsErrorKeywords},
{NameVariable, NameBuiltin, append(emacsBuiltinFunctionHighlighted, emacsMacros...)},
{NameVariable, KeywordPseudo, emacsLambdaListKeywords},
}))

View file

@ -0,0 +1,154 @@
<lexer>
<config>
<name>ABAP</name>
<alias>abap</alias>
<filename>*.abap</filename>
<filename>*.ABAP</filename>
<mime_type>text/x-abap</mime_type>
<case_insensitive>true</case_insensitive>
</config>
<rules>
<state name="common">
<rule pattern="\s+">
<token type="Text"/>
</rule>
<rule pattern="^\*.*$">
<token type="CommentSingle"/>
</rule>
<rule pattern="\&#34;.*?\n">
<token type="CommentSingle"/>
</rule>
<rule pattern="##\w+">
<token type="CommentSpecial"/>
</rule>
</state>
<state name="variable-names">
<rule pattern="&lt;\S+&gt;">
<token type="NameVariable"/>
</rule>
<rule pattern="\w[\w~]*(?:(\[\])|-&gt;\*)?">
<token type="NameVariable"/>
</rule>
</state>
<state name="root">
<rule>
<include state="common"/>
</rule>
<rule pattern="CALL\s+(?:BADI|CUSTOMER-FUNCTION|FUNCTION)">
<token type="Keyword"/>
</rule>
<rule pattern="(CALL\s+(?:DIALOG|SCREEN|SUBSCREEN|SELECTION-SCREEN|TRANSACTION|TRANSFORMATION))\b">
<token type="Keyword"/>
</rule>
<rule pattern="(FORM|PERFORM)(\s+)(\w+)">
<bygroups>
<token type="Keyword"/>
<token type="Text"/>
<token type="NameFunction"/>
</bygroups>
</rule>
<rule pattern="(PERFORM)(\s+)(\()(\w+)(\))">
<bygroups>
<token type="Keyword"/>
<token type="Text"/>
<token type="Punctuation"/>
<token type="NameVariable"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="(MODULE)(\s+)(\S+)(\s+)(INPUT|OUTPUT)">
<bygroups>
<token type="Keyword"/>
<token type="Text"/>
<token type="NameFunction"/>
<token type="Text"/>
<token type="Keyword"/>
</bygroups>
</rule>
<rule pattern="(METHOD)(\s+)([\w~]+)">
<bygroups>
<token type="Keyword"/>
<token type="Text"/>
<token type="NameFunction"/>
</bygroups>
</rule>
<rule pattern="(\s+)([\w\-]+)([=\-]&gt;)([\w\-~]+)">
<bygroups>
<token type="Text"/>
<token type="NameVariable"/>
<token type="Operator"/>
<token type="NameFunction"/>
</bygroups>
</rule>
<rule pattern="(?&lt;=(=|-)&gt;)([\w\-~]+)(?=\()">
<token type="NameFunction"/>
</rule>
<rule pattern="(TEXT)(-)(\d{3})">
<bygroups>
<token type="Keyword"/>
<token type="Punctuation"/>
<token type="LiteralNumberInteger"/>
</bygroups>
</rule>
<rule pattern="(TEXT)(-)(\w{3})">
<bygroups>
<token type="Keyword"/>
<token type="Punctuation"/>
<token type="NameVariable"/>
</bygroups>
</rule>
<rule pattern="(ADD-CORRESPONDING|AUTHORITY-CHECK|CLASS-DATA|CLASS-EVENTS|CLASS-METHODS|CLASS-POOL|DELETE-ADJACENT|DIVIDE-CORRESPONDING|EDITOR-CALL|ENHANCEMENT-POINT|ENHANCEMENT-SECTION|EXIT-COMMAND|FIELD-GROUPS|FIELD-SYMBOLS|FUNCTION-POOL|INTERFACE-POOL|INVERTED-DATE|LOAD-OF-PROGRAM|LOG-POINT|MESSAGE-ID|MOVE-CORRESPONDING|MULTIPLY-CORRESPONDING|NEW-LINE|NEW-PAGE|NEW-SECTION|NO-EXTENSION|OUTPUT-LENGTH|PRINT-CONTROL|SELECT-OPTIONS|START-OF-SELECTION|SUBTRACT-CORRESPONDING|SYNTAX-CHECK|SYSTEM-EXCEPTIONS|TYPE-POOL|TYPE-POOLS|NO-DISPLAY)\b">
<token type="Keyword"/>
</rule>
<rule pattern="(?&lt;![-\&gt;])(CREATE\s+(PUBLIC|PRIVATE|DATA|OBJECT)|(PUBLIC|PRIVATE|PROTECTED)\s+SECTION|(TYPE|LIKE)\s+((LINE\s+OF|REF\s+TO|(SORTED|STANDARD|HASHED)\s+TABLE\s+OF))?|FROM\s+(DATABASE|MEMORY)|CALL\s+METHOD|(GROUP|ORDER) BY|HAVING|SEPARATED BY|GET\s+(BADI|BIT|CURSOR|DATASET|LOCALE|PARAMETER|PF-STATUS|(PROPERTY|REFERENCE)\s+OF|RUN\s+TIME|TIME\s+(STAMP)?)?|SET\s+(BIT|BLANK\s+LINES|COUNTRY|CURSOR|DATASET|EXTENDED\s+CHECK|HANDLER|HOLD\s+DATA|LANGUAGE|LEFT\s+SCROLL-BOUNDARY|LOCALE|MARGIN|PARAMETER|PF-STATUS|PROPERTY\s+OF|RUN\s+TIME\s+(ANALYZER|CLOCK\s+RESOLUTION)|SCREEN|TITLEBAR|UPADTE\s+TASK\s+LOCAL|USER-COMMAND)|CONVERT\s+((INVERTED-)?DATE|TIME|TIME\s+STAMP|TEXT)|(CLOSE|OPEN)\s+(DATASET|CURSOR)|(TO|FROM)\s+(DATA BUFFER|INTERNAL TABLE|MEMORY ID|DATABASE|SHARED\s+(MEMORY|BUFFER))|DESCRIBE\s+(DISTANCE\s+BETWEEN|FIELD|LIST|TABLE)|FREE\s(MEMORY|OBJECT)?|PROCESS\s+(BEFORE\s+OUTPUT|AFTER\s+INPUT|ON\s+(VALUE-REQUEST|HELP-REQUEST))|AT\s+(LINE-SELECTION|USER-COMMAND|END\s+OF|NEW)|AT\s+SELECTION-SCREEN(\s+(ON(\s+(BLOCK|(HELP|VALUE)-REQUEST\s+FOR|END\s+OF|RADIOBUTTON\s+GROUP))?|OUTPUT))?|SELECTION-SCREEN:?\s+((BEGIN|END)\s+OF\s+((TABBED\s+)?BLOCK|LINE|SCREEN)|COMMENT|FUNCTION\s+KEY|INCLUDE\s+BLOCKS|POSITION|PUSHBUTTON|SKIP|ULINE)|LEAVE\s+(LIST-PROCESSING|PROGRAM|SCREEN|TO LIST-PROCESSING|TO TRANSACTION)(ENDING|STARTING)\s+AT|FORMAT\s+(COLOR|INTENSIFIED|INVERSE|HOTSPOT|INPUT|FRAMES|RESET)|AS\s+(CHECKBOX|SUBSCREEN|WINDOW)|WITH\s+(((NON-)?UNIQUE)?\s+KEY|FRAME)|(BEGIN|END)\s+OF|DELETE(\s+ADJACENT\s+DUPLICATES\sFROM)?|COMPARING(\s+ALL\s+FIELDS)?|(INSERT|APPEND)(\s+INITIAL\s+LINE\s+(IN)?TO|\s+LINES\s+OF)?|IN\s+((BYTE|CHARACTER)\s+MODE|PROGRAM)|END-OF-(DEFINITION|PAGE|SELECTION)|WITH\s+FRAME(\s+TITLE)|(REPLACE|FIND)\s+((FIRST|ALL)\s+OCCURRENCES?\s+OF\s+)?(SUBSTRING|REGEX)?|MATCH\s+(LENGTH|COUNT|LINE|OFFSET)|(RESPECTING|IGNORING)\s+CASE|IN\s+UPDATE\s+TASK|(SOURCE|RESULT)\s+(XML)?|REFERENCE\s+INTO|AND\s+(MARK|RETURN)|CLIENT\s+SPECIFIED|CORRESPONDING\s+FIELDS\s+OF|IF\s+FOUND|FOR\s+EVENT|INHERITING\s+FROM|LEAVE\s+TO\s+SCREEN|LOOP\s+AT\s+(SCREEN)?|LOWER\s+CASE|MATCHCODE\s+OBJECT|MODIF\s+ID|MODIFY\s+SCREEN|NESTING\s+LEVEL|NO\s+INTERVALS|OF\s+STRUCTURE|RADIOBUTTON\s+GROUP|RANGE\s+OF|REF\s+TO|SUPPRESS DIALOG|TABLE\s+OF|UPPER\s+CASE|TRANSPORTING\s+NO\s+FIELDS|VALUE\s+CHECK|VISIBLE\s+LENGTH|HEADER\s+LINE|COMMON\s+PART)\b">
<token type="Keyword"/>
</rule>
<rule pattern="(^|(?&lt;=(\s|\.)))(ABBREVIATED|ABSTRACT|ADD|ALIASES|ALIGN|ALPHA|ASSERT|AS|ASSIGN(ING)?|AT(\s+FIRST)?|BACK|BLOCK|BREAK-POINT|CASE|CATCH|CHANGING|CHECK|CLASS|CLEAR|COLLECT|COLOR|COMMIT|CREATE|COMMUNICATION|COMPONENTS?|COMPUTE|CONCATENATE|CONDENSE|CONSTANTS|CONTEXTS|CONTINUE|CONTROLS|COUNTRY|CURRENCY|DATA|DATE|DECIMALS|DEFAULT|DEFINE|DEFINITION|DEFERRED|DEMAND|DETAIL|DIRECTORY|DIVIDE|DO|DUMMY|ELSE(IF)?|ENDAT|ENDCASE|ENDCATCH|ENDCLASS|ENDDO|ENDFORM|ENDFUNCTION|ENDIF|ENDINTERFACE|ENDLOOP|ENDMETHOD|ENDMODULE|ENDSELECT|ENDTRY|ENDWHILE|ENHANCEMENT|EVENTS|EXACT|EXCEPTIONS?|EXIT|EXPONENT|EXPORT|EXPORTING|EXTRACT|FETCH|FIELDS?|FOR|FORM|FORMAT|FREE|FROM|FUNCTION|HIDE|ID|IF|IMPORT|IMPLEMENTATION|IMPORTING|IN|INCLUDE|INCLUDING|INDEX|INFOTYPES|INITIALIZATION|INTERFACE|INTERFACES|INTO|LANGUAGE|LEAVE|LENGTH|LINES|LOAD|LOCAL|JOIN|KEY|NEXT|MAXIMUM|MESSAGE|METHOD[S]?|MINIMUM|MODULE|MODIFIER|MODIFY|MOVE|MULTIPLY|NODES|NUMBER|OBLIGATORY|OBJECT|OF|OFF|ON|OTHERS|OVERLAY|PACK|PAD|PARAMETERS|PERCENTAGE|POSITION|PROGRAM|PROVIDE|PUBLIC|PUT|PF\d\d|RAISE|RAISING|RANGES?|READ|RECEIVE|REDEFINITION|REFRESH|REJECT|REPORT|RESERVE|RESUME|RETRY|RETURN|RETURNING|RIGHT|ROLLBACK|REPLACE|SCROLL|SEARCH|SELECT|SHIFT|SIGN|SINGLE|SIZE|SKIP|SORT|SPLIT|STATICS|STOP|STYLE|SUBMATCHES|SUBMIT|SUBTRACT|SUM(?!\()|SUMMARY|SUMMING|SUPPLY|TABLE|TABLES|TIMESTAMP|TIMES?|TIMEZONE|TITLE|\??TO|TOP-OF-PAGE|TRANSFER|TRANSLATE|TRY|TYPES|ULINE|UNDER|UNPACK|UPDATE|USING|VALUE|VALUES|VIA|VARYING|VARY|WAIT|WHEN|WHERE|WIDTH|WHILE|WITH|WINDOW|WRITE|XSD|ZERO)\b">
<token type="Keyword"/>
</rule>
<rule pattern="(abs|acos|asin|atan|boolc|boolx|bit_set|char_off|charlen|ceil|cmax|cmin|condense|contains|contains_any_of|contains_any_not_of|concat_lines_of|cos|cosh|count|count_any_of|count_any_not_of|dbmaxlen|distance|escape|exp|find|find_end|find_any_of|find_any_not_of|floor|frac|from_mixed|insert|lines|log|log10|match|matches|nmax|nmin|numofchar|repeat|replace|rescale|reverse|round|segment|shift_left|shift_right|sign|sin|sinh|sqrt|strlen|substring|substring_after|substring_from|substring_before|substring_to|tan|tanh|to_upper|to_lower|to_mixed|translate|trunc|xstrlen)(\()\b">
<bygroups>
<token type="NameBuiltin"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="&amp;[0-9]">
<token type="Name"/>
</rule>
<rule pattern="[0-9]+">
<token type="LiteralNumberInteger"/>
</rule>
<rule pattern="(?&lt;=(\s|.))(AND|OR|EQ|NE|GT|LT|GE|LE|CO|CN|CA|NA|CS|NOT|NS|CP|NP|BYTE-CO|BYTE-CN|BYTE-CA|BYTE-NA|BYTE-CS|BYTE-NS|IS\s+(NOT\s+)?(INITIAL|ASSIGNED|REQUESTED|BOUND))\b">
<token type="OperatorWord"/>
</rule>
<rule>
<include state="variable-names"/>
</rule>
<rule pattern="[?*&lt;&gt;=\-+&amp;]">
<token type="Operator"/>
</rule>
<rule pattern="&#39;(&#39;&#39;|[^&#39;])*&#39;">
<token type="LiteralStringSingle"/>
</rule>
<rule pattern="`([^`])*`">
<token type="LiteralStringSingle"/>
</rule>
<rule pattern="([|}])([^{}|]*?)([|{])">
<bygroups>
<token type="Punctuation"/>
<token type="LiteralStringSingle"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="[/;:()\[\],.]">
<token type="Punctuation"/>
</rule>
<rule pattern="(!)(\w+)">
<bygroups>
<token type="Operator"/>
<token type="Name"/>
</bygroups>
</rule>
</state>
</rules>
</lexer>

View file

@ -0,0 +1,66 @@
<lexer>
<config>
<name>ABNF</name>
<alias>abnf</alias>
<filename>*.abnf</filename>
<mime_type>text/x-abnf</mime_type>
</config>
<rules>
<state name="root">
<rule pattern=";.*$">
<token type="CommentSingle"/>
</rule>
<rule pattern="(%[si])?&#34;[^&#34;]*&#34;">
<token type="Literal"/>
</rule>
<rule pattern="%b[01]+\-[01]+\b">
<token type="Literal"/>
</rule>
<rule pattern="%b[01]+(\.[01]+)*\b">
<token type="Literal"/>
</rule>
<rule pattern="%d[0-9]+\-[0-9]+\b">
<token type="Literal"/>
</rule>
<rule pattern="%d[0-9]+(\.[0-9]+)*\b">
<token type="Literal"/>
</rule>
<rule pattern="%x[0-9a-fA-F]+\-[0-9a-fA-F]+\b">
<token type="Literal"/>
</rule>
<rule pattern="%x[0-9a-fA-F]+(\.[0-9a-fA-F]+)*\b">
<token type="Literal"/>
</rule>
<rule pattern="\b[0-9]+\*[0-9]+">
<token type="Operator"/>
</rule>
<rule pattern="\b[0-9]+\*">
<token type="Operator"/>
</rule>
<rule pattern="\b[0-9]+">
<token type="Operator"/>
</rule>
<rule pattern="\*">
<token type="Operator"/>
</rule>
<rule pattern="(HEXDIG|DQUOTE|DIGIT|VCHAR|OCTET|ALPHA|CHAR|CRLF|HTAB|LWSP|BIT|CTL|WSP|LF|SP|CR)\b">
<token type="Keyword"/>
</rule>
<rule pattern="[a-zA-Z][a-zA-Z0-9-]+\b">
<token type="NameClass"/>
</rule>
<rule pattern="(=/|=|/)">
<token type="Operator"/>
</rule>
<rule pattern="[\[\]()]">
<token type="Punctuation"/>
</rule>
<rule pattern="\s+">
<token type="Text"/>
</rule>
<rule pattern=".">
<token type="Text"/>
</rule>
</state>
</rules>
</lexer>

View file

@ -0,0 +1,68 @@
<lexer>
<config>
<name>ActionScript</name>
<alias>as</alias>
<alias>actionscript</alias>
<filename>*.as</filename>
<mime_type>application/x-actionscript</mime_type>
<mime_type>text/x-actionscript</mime_type>
<mime_type>text/actionscript</mime_type>
<dot_all>true</dot_all>
<not_multiline>true</not_multiline>
</config>
<rules>
<state name="root">
<rule pattern="\s+">
<token type="Text"/>
</rule>
<rule pattern="//.*?\n">
<token type="CommentSingle"/>
</rule>
<rule pattern="/\*.*?\*/">
<token type="CommentMultiline"/>
</rule>
<rule pattern="/(\\\\|\\/|[^/\n])*/[gim]*">
<token type="LiteralStringRegex"/>
</rule>
<rule pattern="[~^*!%&amp;&lt;&gt;|+=:;,/?\\-]+">
<token type="Operator"/>
</rule>
<rule pattern="[{}\[\]();.]+">
<token type="Punctuation"/>
</rule>
<rule pattern="(instanceof|arguments|continue|default|typeof|switch|return|catch|break|while|throw|each|this|with|else|case|var|new|for|try|if|do|in)\b">
<token type="Keyword"/>
</rule>
<rule pattern="(implements|protected|namespace|interface|intrinsic|override|function|internal|private|package|extends|dynamic|import|native|return|public|static|class|const|super|final|get|set)\b">
<token type="KeywordDeclaration"/>
</rule>
<rule pattern="(true|false|null|NaN|Infinity|-Infinity|undefined|Void)\b">
<token type="KeywordConstant"/>
</rule>
<rule pattern="(IDynamicPropertyOutputIDynamicPropertyWriter|DisplacmentMapFilterMode|AccessibilityProperties|ContextMenuBuiltInItems|SharedObjectFlushStatus|DisplayObjectContainer|IllegalOperationError|DisplacmentMapFilter|InterpolationMethod|URLLoaderDataFormat|PrintJobOrientation|ActionScriptVersion|BitmapFilterQuality|GradientBevelFilter|GradientGlowFilter|DeleteObjectSample|StackOverflowError|SoundLoaderContext|ScriptTimeoutError|SecurityErrorEvent|InteractiveObject|StageDisplayState|FileReferenceList|TextFieldAutoSize|ApplicationDomain|BitmapDataChannel|ColorMatrixFilter|ExternalInterface|IMEConversionMode|DropShadowFilter|URLRequestHeader|ContextMenuEvent|ConvultionFilter|URLRequestMethod|BitmapFilterType|IEventDispatcher|ContextMenuItem|LocalConnection|InvalidSWFError|AsyncErrorEvent|MovieClipLoader|IBitmapDrawable|PrintJobOptions|EventDispatcher|NewObjectSample|HTTPStatusEvent|TextFormatAlign|IExternalizable|FullScreenEvent|DefinitionError|TextLineMetrics|NetStatusEvent|ColorTransform|ObjectEncoding|SecurityDomain|StageScaleMode|FocusDirection|ReferenceError|SoundTransform|KeyboardEvent|DisplayObject|PixelSnapping|LoaderContext|NetConnection|SecurityPanel|SecurityError|FileReference|AsBroadcaster|LineScaleMode|AntiAliasType|Accessibility|TextFieldType|URLVariabeles|ActivityEvent|ProgressEvent|TextColorType|StageQuality|TextSnapshot|Capabilities|BitmapFilter|SpreadMethod|GradientType|TextRenderer|SoundChannel|SharedObject|IOErrorEvent|SimpleButton|ContextMenu|InvokeEvent|CSMSettings|SyntaxError|StatusEvent|KeyLocation|IDataOutput|VerifyError|XMLDocument|XMLNodeType|MemoryError|GridFitType|BevelFilter|ErrorEvent|FrameLabel|GlowFilter|LoaderInfo|Microphone|MorphShape|BlurFilter|MouseEvent|FocusEvent|SoundMixer|FileFilter|TimerEvent|JointStyle|EventPhase|StageAlign|Dictionary|URLRequest|StyleSheet|SWFVersion|IDataInput|StaticText|RangeError|BitmapData|TextFormat|StackFrame|Namespace|SyncEvent|Rectangle|URLLoader|TypeError|Responder|NetStream|BlendMode|CapsStyle|DataEvent|ByteArray|MovieClip|Transform|TextField|Selection|AVM1Movie|XMLSocket|URLStream|FontStyle|EvalError|FontType|LoadVars|Graphics|Security|IMEEvent|URIError|Keyboard|Function|EOFError|PrintJob|IOError|XMLList|Boolean|ID3Info|XMLNode|Bitmap|String|RegExp|Sample|Object|Sprite|System|Endian|Matrix|Camera|Locale|Number|Loader|Socket|QName|Class|Timer|Sound|Shape|XMLUI|Mouse|Scene|Stage|Color|Point|Video|Error|Event|Proxy|Array|Date|uint|Math|Font|int|Key|IME|XML)\b">
<token type="NameBuiltin"/>
</rule>
<rule pattern="(decodeURIComponent|updateAfterEvent|clearInterval|setInterval|getVersion|parseFloat|fscommand|isXMLName|encodeURI|decodeURI|getTimer|unescape|isFinite|parseInt|getURL|escape|trace|isNaN|eval)\b">
<token type="NameFunction"/>
</rule>
<rule pattern="[$a-zA-Z_]\w*">
<token type="NameOther"/>
</rule>
<rule pattern="[0-9][0-9]*\.[0-9]+([eE][0-9]+)?[fd]?">
<token type="LiteralNumberFloat"/>
</rule>
<rule pattern="0x[0-9a-f]+">
<token type="LiteralNumberHex"/>
</rule>
<rule pattern="[0-9]+">
<token type="LiteralNumberInteger"/>
</rule>
<rule pattern="&#34;(\\\\|\\&#34;|[^&#34;])*&#34;">
<token type="LiteralStringDouble"/>
</rule>
<rule pattern="&#39;(\\\\|\\&#39;|[^&#39;])*&#39;">
<token type="LiteralStringSingle"/>
</rule>
</state>
</rules>
</lexer>

View file

@ -0,0 +1,163 @@
<lexer>
<config>
<name>ActionScript 3</name>
<alias>as3</alias>
<alias>actionscript3</alias>
<filename>*.as</filename>
<mime_type>application/x-actionscript3</mime_type>
<mime_type>text/x-actionscript3</mime_type>
<mime_type>text/actionscript3</mime_type>
<dot_all>true</dot_all>
</config>
<rules>
<state name="funcparams">
<rule pattern="\s+">
<token type="Text"/>
</rule>
<rule pattern="(\s*)(\.\.\.)?([$a-zA-Z_]\w*)(\s*)(:)(\s*)([$a-zA-Z_]\w*(?:\.&lt;\w+&gt;)?|\*)(\s*)">
<bygroups>
<token type="Text"/>
<token type="Punctuation"/>
<token type="Name"/>
<token type="Text"/>
<token type="Operator"/>
<token type="Text"/>
<token type="KeywordType"/>
<token type="Text"/>
</bygroups>
<push state="defval"/>
</rule>
<rule pattern="\)">
<token type="Operator"/>
<push state="type"/>
</rule>
</state>
<state name="type">
<rule pattern="(\s*)(:)(\s*)([$a-zA-Z_]\w*(?:\.&lt;\w+&gt;)?|\*)">
<bygroups>
<token type="Text"/>
<token type="Operator"/>
<token type="Text"/>
<token type="KeywordType"/>
</bygroups>
<pop depth="2"/>
</rule>
<rule pattern="\s+">
<token type="Text"/>
<pop depth="2"/>
</rule>
<rule>
<pop depth="2"/>
</rule>
</state>
<state name="defval">
<rule pattern="(=)(\s*)([^(),]+)(\s*)(,?)">
<bygroups>
<token type="Operator"/>
<token type="Text"/>
<usingself state="root"/>
<token type="Text"/>
<token type="Operator"/>
</bygroups>
<pop depth="1"/>
</rule>
<rule pattern=",">
<token type="Operator"/>
<pop depth="1"/>
</rule>
<rule>
<pop depth="1"/>
</rule>
</state>
<state name="root">
<rule pattern="\s+">
<token type="Text"/>
</rule>
<rule pattern="(function\s+)([$a-zA-Z_]\w*)(\s*)(\()">
<bygroups>
<token type="KeywordDeclaration"/>
<token type="NameFunction"/>
<token type="Text"/>
<token type="Operator"/>
</bygroups>
<push state="funcparams"/>
</rule>
<rule pattern="(var|const)(\s+)([$a-zA-Z_]\w*)(\s*)(:)(\s*)([$a-zA-Z_]\w*(?:\.&lt;\w+&gt;)?)">
<bygroups>
<token type="KeywordDeclaration"/>
<token type="Text"/>
<token type="Name"/>
<token type="Text"/>
<token type="Punctuation"/>
<token type="Text"/>
<token type="KeywordType"/>
</bygroups>
</rule>
<rule pattern="(import|package)(\s+)((?:[$a-zA-Z_]\w*|\.)+)(\s*)">
<bygroups>
<token type="Keyword"/>
<token type="Text"/>
<token type="NameNamespace"/>
<token type="Text"/>
</bygroups>
</rule>
<rule pattern="(new)(\s+)([$a-zA-Z_]\w*(?:\.&lt;\w+&gt;)?)(\s*)(\()">
<bygroups>
<token type="Keyword"/>
<token type="Text"/>
<token type="KeywordType"/>
<token type="Text"/>
<token type="Operator"/>
</bygroups>
</rule>
<rule pattern="//.*?\n">
<token type="CommentSingle"/>
</rule>
<rule pattern="/\*.*?\*/">
<token type="CommentMultiline"/>
</rule>
<rule pattern="/(\\\\|\\/|[^\n])*/[gisx]*">
<token type="LiteralStringRegex"/>
</rule>
<rule pattern="(\.)([$a-zA-Z_]\w*)">
<bygroups>
<token type="Operator"/>
<token type="NameAttribute"/>
</bygroups>
</rule>
<rule pattern="(case|default|for|each|in|while|do|break|return|continue|if|else|throw|try|catch|with|new|typeof|arguments|instanceof|this|switch|import|include|as|is)\b">
<token type="Keyword"/>
</rule>
<rule pattern="(class|public|final|internal|native|override|private|protected|static|import|extends|implements|interface|intrinsic|return|super|dynamic|function|const|get|namespace|package|set)\b">
<token type="KeywordDeclaration"/>
</rule>
<rule pattern="(true|false|null|NaN|Infinity|-Infinity|undefined|void)\b">
<token type="KeywordConstant"/>
</rule>
<rule pattern="(decodeURI|decodeURIComponent|encodeURI|escape|eval|isFinite|isNaN|isXMLName|clearInterval|fscommand|getTimer|getURL|getVersion|isFinite|parseFloat|parseInt|setInterval|trace|updateAfterEvent|unescape)\b">
<token type="NameFunction"/>
</rule>
<rule pattern="[$a-zA-Z_]\w*">
<token type="Name"/>
</rule>
<rule pattern="[0-9][0-9]*\.[0-9]+([eE][0-9]+)?[fd]?">
<token type="LiteralNumberFloat"/>
</rule>
<rule pattern="0x[0-9a-f]+">
<token type="LiteralNumberHex"/>
</rule>
<rule pattern="[0-9]+">
<token type="LiteralNumberInteger"/>
</rule>
<rule pattern="&#34;(\\\\|\\&#34;|[^&#34;])*&#34;">
<token type="LiteralStringDouble"/>
</rule>
<rule pattern="&#39;(\\\\|\\&#39;|[^&#39;])*&#39;">
<token type="LiteralStringSingle"/>
</rule>
<rule pattern="[~^*!%&amp;&lt;&gt;|+=:;,/?\\{}\[\]().-]+">
<token type="Operator"/>
</rule>
</state>
</rules>
</lexer>

View file

@ -0,0 +1,321 @@
<lexer>
<config>
<name>Ada</name>
<alias>ada</alias>
<alias>ada95</alias>
<alias>ada2005</alias>
<filename>*.adb</filename>
<filename>*.ads</filename>
<filename>*.ada</filename>
<mime_type>text/x-ada</mime_type>
<case_insensitive>true</case_insensitive>
</config>
<rules>
<state name="end">
<rule pattern="(if|case|record|loop|select)">
<token type="KeywordReserved"/>
</rule>
<rule pattern="&#34;[^&#34;]+&#34;|[\w.]+">
<token type="NameFunction"/>
</rule>
<rule pattern="\s+">
<token type="Text"/>
</rule>
<rule pattern=";">
<token type="Punctuation"/>
<pop depth="1"/>
</rule>
</state>
<state name="array_def">
<rule pattern=";">
<token type="Punctuation"/>
<pop depth="1"/>
</rule>
<rule pattern="(\w+)(\s+)(range)">
<bygroups>
<token type="KeywordType"/>
<token type="Text"/>
<token type="KeywordReserved"/>
</bygroups>
</rule>
<rule>
<include state="root"/>
</rule>
</state>
<state name="package_instantiation">
<rule pattern="(&#34;[^&#34;]+&#34;|\w+)(\s+)(=&gt;)">
<bygroups>
<token type="NameVariable"/>
<token type="Text"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="[\w.\&#39;&#34;]">
<token type="Text"/>
</rule>
<rule pattern="\)">
<token type="Punctuation"/>
<pop depth="1"/>
</rule>
<rule>
<include state="root"/>
</rule>
</state>
<state name="subprogram">
<rule pattern="\(">
<token type="Punctuation"/>
<push state="#pop" state="formal_part"/>
</rule>
<rule pattern=";">
<token type="Punctuation"/>
<pop depth="1"/>
</rule>
<rule pattern="is\b">
<token type="KeywordReserved"/>
<pop depth="1"/>
</rule>
<rule pattern="&#34;[^&#34;]+&#34;|\w+">
<token type="NameFunction"/>
</rule>
<rule>
<include state="root"/>
</rule>
</state>
<state name="type_def">
<rule pattern=";">
<token type="Punctuation"/>
<pop depth="1"/>
</rule>
<rule pattern="\(">
<token type="Punctuation"/>
<push state="formal_part"/>
</rule>
<rule pattern="with|and|use">
<token type="KeywordReserved"/>
</rule>
<rule pattern="array\b">
<token type="KeywordReserved"/>
<push state="#pop" state="array_def"/>
</rule>
<rule pattern="record\b">
<token type="KeywordReserved"/>
<push state="record_def"/>
</rule>
<rule pattern="(null record)(;)">
<bygroups>
<token type="KeywordReserved"/>
<token type="Punctuation"/>
</bygroups>
<pop depth="1"/>
</rule>
<rule>
<include state="root"/>
</rule>
</state>
<state name="import">
<rule pattern="[\w.]+">
<token type="NameNamespace"/>
<pop depth="1"/>
</rule>
<rule>
<pop depth="1"/>
</rule>
</state>
<state name="formal_part">
<rule pattern="\)">
<token type="Punctuation"/>
<pop depth="1"/>
</rule>
<rule pattern="\w+">
<token type="NameVariable"/>
</rule>
<rule pattern=",|:[^=]">
<token type="Punctuation"/>
</rule>
<rule pattern="(in|not|null|out|access)\b">
<token type="KeywordReserved"/>
</rule>
<rule>
<include state="root"/>
</rule>
</state>
<state name="package">
<rule pattern="body">
<token type="KeywordDeclaration"/>
</rule>
<rule pattern="is\s+new|renames">
<token type="KeywordReserved"/>
</rule>
<rule pattern="is">
<token type="KeywordReserved"/>
<pop depth="1"/>
</rule>
<rule pattern=";">
<token type="Punctuation"/>
<pop depth="1"/>
</rule>
<rule pattern="\(">
<token type="Punctuation"/>
<push state="package_instantiation"/>
</rule>
<rule pattern="([\w.]+)">
<token type="NameClass"/>
</rule>
<rule>
<include state="root"/>
</rule>
</state>
<state name="attribute">
<rule pattern="(&#39;)(\w+)">
<bygroups>
<token type="Punctuation"/>
<token type="NameAttribute"/>
</bygroups>
</rule>
</state>
<state name="record_def">
<rule pattern="end record">
<token type="KeywordReserved"/>
<pop depth="1"/>
</rule>
<rule>
<include state="root"/>
</rule>
</state>
<state name="root">
<rule pattern="[^\S\n]+">
<token type="Text"/>
</rule>
<rule pattern="--.*?\n">
<token type="CommentSingle"/>
</rule>
<rule pattern="[^\S\n]+">
<token type="Text"/>
</rule>
<rule pattern="function|procedure|entry">
<token type="KeywordDeclaration"/>
<push state="subprogram"/>
</rule>
<rule pattern="(subtype|type)(\s+)(\w+)">
<bygroups>
<token type="KeywordDeclaration"/>
<token type="Text"/>
<token type="KeywordType"/>
</bygroups>
<push state="type_def"/>
</rule>
<rule pattern="task|protected">
<token type="KeywordDeclaration"/>
</rule>
<rule pattern="(subtype)(\s+)">
<bygroups>
<token type="KeywordDeclaration"/>
<token type="Text"/>
</bygroups>
</rule>
<rule pattern="(end)(\s+)">
<bygroups>
<token type="KeywordReserved"/>
<token type="Text"/>
</bygroups>
<push state="end"/>
</rule>
<rule pattern="(pragma)(\s+)(\w+)">
<bygroups>
<token type="KeywordReserved"/>
<token type="Text"/>
<token type="CommentPreproc"/>
</bygroups>
</rule>
<rule pattern="(true|false|null)\b">
<token type="KeywordConstant"/>
</rule>
<rule pattern="(Short_Short_Integer|Short_Short_Float|Long_Long_Integer|Long_Long_Float|Wide_Character|Reference_Type|Short_Integer|Long_Integer|Wide_String|Short_Float|Controlled|Long_Float|Character|Generator|File_Type|File_Mode|Positive|Duration|Boolean|Natural|Integer|Address|Cursor|String|Count|Float|Byte)\b">
<token type="KeywordType"/>
</rule>
<rule pattern="(and(\s+then)?|in|mod|not|or(\s+else)|rem)\b">
<token type="OperatorWord"/>
</rule>
<rule pattern="generic|private">
<token type="KeywordDeclaration"/>
</rule>
<rule pattern="package">
<token type="KeywordDeclaration"/>
<push state="package"/>
</rule>
<rule pattern="array\b">
<token type="KeywordReserved"/>
<push state="array_def"/>
</rule>
<rule pattern="(with|use)(\s+)">
<bygroups>
<token type="KeywordNamespace"/>
<token type="Text"/>
</bygroups>
<push state="import"/>
</rule>
<rule pattern="(\w+)(\s*)(:)(\s*)(constant)">
<bygroups>
<token type="NameConstant"/>
<token type="Text"/>
<token type="Punctuation"/>
<token type="Text"/>
<token type="KeywordReserved"/>
</bygroups>
</rule>
<rule pattern="&lt;&lt;\w+&gt;&gt;">
<token type="NameLabel"/>
</rule>
<rule pattern="(\w+)(\s*)(:)(\s*)(declare|begin|loop|for|while)">
<bygroups>
<token type="NameLabel"/>
<token type="Text"/>
<token type="Punctuation"/>
<token type="Text"/>
<token type="KeywordReserved"/>
</bygroups>
</rule>
<rule pattern="\b(synchronized|overriding|terminate|interface|exception|protected|separate|constant|abstract|renames|reverse|subtype|aliased|declare|requeue|limited|return|tagged|access|record|select|accept|digits|others|pragma|entry|elsif|delta|delay|array|until|range|raise|while|begin|abort|else|loop|when|type|null|then|body|task|goto|case|exit|end|for|abs|xor|all|new|out|is|of|if|or|do|at)\b">
<token type="KeywordReserved"/>
</rule>
<rule pattern="&#34;[^&#34;]*&#34;">
<token type="LiteralString"/>
</rule>
<rule>
<include state="attribute"/>
</rule>
<rule>
<include state="numbers"/>
</rule>
<rule pattern="&#39;[^&#39;]&#39;">
<token type="LiteralStringChar"/>
</rule>
<rule pattern="(\w+)(\s*|[(,])">
<bygroups>
<token type="Name"/>
<usingself state="root"/>
</bygroups>
</rule>
<rule pattern="(&lt;&gt;|=&gt;|:=|[()|:;,.&#39;])">
<token type="Punctuation"/>
</rule>
<rule pattern="[*&lt;&gt;+=/&amp;-]">
<token type="Operator"/>
</rule>
<rule pattern="\n+">
<token type="Text"/>
</rule>
</state>
<state name="numbers">
<rule pattern="[0-9_]+#[0-9a-f]+#">
<token type="LiteralNumberHex"/>
</rule>
<rule pattern="[0-9_]+\.[0-9_]*">
<token type="LiteralNumberFloat"/>
</rule>
<rule pattern="[0-9_]+">
<token type="LiteralNumberInteger"/>
</rule>
</state>
</rules>
</lexer>

View file

@ -0,0 +1,66 @@
<lexer>
<config>
<name>Agda</name>
<alias>agda</alias>
<filename>*.agda</filename>
<mime_type>text/x-agda</mime_type>
</config>
<rules>
<state name="root">
<rule pattern="^(\s*)([^\s(){}]+)(\s*)(:)(\s*)"><bygroups><token type="TextWhitespace"/><token type="NameFunction"/><token type="TextWhitespace"/><token type="OperatorWord"/><token type="TextWhitespace"/></bygroups></rule>
<rule pattern="--(?![!#$%&amp;*+./&lt;=&gt;?@^|_~:\\]).*?$"><token type="CommentSingle"/></rule>
<rule pattern="\{-"><token type="CommentMultiline"/><push state="comment"/></rule>
<rule pattern="\{!"><token type="CommentMultiline"/><push state="hole"/></rule>
<rule pattern="\b(abstract|codata|coinductive|constructor|data|do|eta-equality|field|forall|hiding|in|inductive|infix|infixl|infixr|instance|interleaved|let|macro|mutual|no-eta-equality|open|overlap|pattern|postulate|primitive|private|quote|quoteTerm|record|renaming|rewrite|syntax|tactic|unquote|unquoteDecl|unquoteDef|using|variable|where|with)(?!\&#x27;)\b"><token type="KeywordReserved"/></rule>
<rule pattern="(import|module)(\s+)"><bygroups><token type="KeywordReserved"/><token type="TextWhitespace"/></bygroups><push state="module"/></rule>
<rule pattern="\b(Set|Prop)[\u2080-\u2089]*\b"><token type="KeywordType"/></rule>
<rule pattern="(\(|\)|\{|\})"><token type="Operator"/></rule>
<rule pattern="(\.{1,3}|\||\u03BB|\u2200|\u2192|:|=|-&gt;)"><token type="OperatorWord"/></rule>
<rule pattern="\d+[eE][+-]?\d+"><token type="LiteralNumberFloat"/></rule>
<rule pattern="\d+\.\d+([eE][+-]?\d+)?"><token type="LiteralNumberFloat"/></rule>
<rule pattern="0[xX][\da-fA-F]+"><token type="LiteralNumberHex"/></rule>
<rule pattern="\d+"><token type="LiteralNumberInteger"/></rule>
<rule pattern="&#x27;"><token type="LiteralStringChar"/><push state="character"/></rule>
<rule pattern="&quot;"><token type="LiteralString"/><push state="string"/></rule>
<rule pattern="[^\s(){}]+"><token type="Text"/></rule>
<rule pattern="\s+?"><token type="TextWhitespace"/></rule>
</state>
<state name="hole">
<rule pattern="[^!{}]+"><token type="CommentMultiline"/></rule>
<rule pattern="\{!"><token type="CommentMultiline"/><push/></rule>
<rule pattern="!\}"><token type="CommentMultiline"/><pop depth="1"/></rule>
<rule pattern="[!{}]"><token type="CommentMultiline"/></rule>
</state>
<state name="module">
<rule pattern="\{-"><token type="CommentMultiline"/><push state="comment"/></rule>
<rule pattern="[a-zA-Z][\w.\&#x27;]*"><token type="Name"/><pop depth="1"/></rule>
<rule pattern="[\W0-9_]+"><token type="Text"/></rule>
</state>
<state name="comment">
<rule pattern="[^-{}]+"><token type="CommentMultiline"/></rule>
<rule pattern="\{-"><token type="CommentMultiline"/><push/></rule>
<rule pattern="-\}"><token type="CommentMultiline"/><pop depth="1"/></rule>
<rule pattern="[-{}]"><token type="CommentMultiline"/></rule>
</state>
<state name="character">
<rule pattern="[^\\&#x27;]&#x27;"><token type="LiteralStringChar"/><pop depth="1"/></rule>
<rule pattern="\\"><token type="LiteralStringEscape"/><push state="escape"/></rule>
<rule pattern="&#x27;"><token type="LiteralStringChar"/><pop depth="1"/></rule>
</state>
<state name="string">
<rule pattern="[^\\&quot;]+"><token type="LiteralString"/></rule>
<rule pattern="\\"><token type="LiteralStringEscape"/><push state="escape"/></rule>
<rule pattern="&quot;"><token type="LiteralString"/><pop depth="1"/></rule>
</state>
<state name="escape">
<rule pattern="[abfnrtv&quot;\&#x27;&amp;\\]"><token type="LiteralStringEscape"/><pop depth="1"/></rule>
<rule pattern="\^[][A-ZÀ-ÖØ-ÞĀĂĄĆĈĊČĎĐĒĔĖĘĚĜĞĠĢĤĦĨĪĬĮİIJĴĶĹĻĽĿŁŃŅŇŊŌŎŐŒŔŖŘŚŜŞŠŢŤŦŨŪŬŮŰŲŴŶŸ-ŹŻŽƁ-ƂƄƆ-ƇƉ-ƋƎ-ƑƓ-ƔƖ-ƘƜ-ƝƟ-ƠƢƤƦ-ƧƩƬƮ-ƯƱ-ƳƵƷ-ƸƼDŽLJNJǍǏǑǓǕǗǙǛǞǠǢǤǦǨǪǬǮDZǴǶ-ǸǺǼǾȀȂȄȆȈȊȌȎȐȒȔȖȘȚȜȞȠȢȤȦȨȪȬȮȰȲȺ-ȻȽ-ȾɁɃ-ɆɈɊɌɎͰͲͶͿΆΈ-ΊΌΎ-ΏΑ-ΡΣ-ΫϏϒ-ϔϘϚϜϞϠϢϤϦϨϪϬϮϴϷϹ-ϺϽ-ЯѠѢѤѦѨѪѬѮѰѲѴѶѸѺѼѾҀҊҌҎҐҒҔҖҘҚҜҞҠҢҤҦҨҪҬҮҰҲҴҶҸҺҼҾӀ-ӁӃӅӇӉӋӍӐӒӔӖӘӚӜӞӠӢӤӦӨӪӬӮӰӲӴӶӸӺӼӾԀԂԄԆԈԊԌԎԐԒԔԖԘԚԜԞԠԢԤԦԨԪԬԮԱ-ՖႠ-ჅჇჍᎠ-ᏵᲐ-ᲺᲽ-ᲿḀḂḄḆḈḊḌḎḐḒḔḖḘḚḜḞḠḢḤḦḨḪḬḮḰḲḴḶḸḺḼḾṀṂṄṆṈṊṌṎṐṒṔṖṘṚṜṞṠṢṤṦṨṪṬṮṰṲṴṶṸṺṼṾẀẂẄẆẈẊẌẎẐẒẔẞẠẢẤẦẨẪẬẮẰẲẴẶẸẺẼẾỀỂỄỆỈỊỌỎỐỒỔỖỘỚỜỞỠỢỤỦỨỪỬỮỰỲỴỶỸỺỼỾἈ-ἏἘ-ἝἨ-ἯἸ-ἿὈ-ὍὙὛὝὟὨ-ὯᾸ-ΆῈ-ΉῘ-ΊῨ-ῬῸ-Ώℂℇℋ---ℝℤΩℨK--ℳℾ-ℿⅅↃⰀ-ⰮⱠⱢ-ⱤⱧⱩⱫⱭ-ⱰⱲⱵⱾ-ⲀⲂⲄⲆⲈⲊⲌⲎⲐⲒⲔⲖⲘⲚⲜⲞⲠⲢⲤⲦⲨⲪⲬⲮⲰⲲⲴⲶⲸⲺⲼⲾⳀⳂⳄⳆⳈⳊⳌⳎⳐⳒⳔⳖⳘⳚⳜⳞⳠⳢⳫⳭⳲꙀꙂꙄꙆꙈꙊꙌꙎꙐꙒꙔꙖꙘꙚꙜꙞꙠꙢꙤꙦꙨꙪꙬꚀꚂꚄꚆꚈꚊꚌꚎꚐꚒꚔꚖꚘꚚꜢꜤꜦꜨꜪꜬꜮꜲꜴꜶꜸꜺꜼꜾꝀꝂꝄꝆꝈꝊꝌꝎꝐꝒꝔꝖꝘꝚꝜꝞꝠꝢꝤꝦꝨꝪꝬꝮꝹꝻꝽ-ꝾꞀꞂꞄꞆꞋꞍꞐꞒꞖꞘꞚꞜꞞꞠꞢꞤꞦꞨꞪ-ꞮꞰ-ꞴꞶꞸA-Z𐐀-𐐧𐒰-𐓓𐲀-𐲲𑢠-𑢿𖹀-𖹟𝐀-𝐙𝐴-𝑍𝑨-𝒁𝒜𝒞-𝒟𝒢𝒥-𝒦𝒩-𝒬𝒮-𝒵𝓐-𝓩𝔄-𝔅𝔇-𝔊𝔍-𝔔𝔖-𝔜𝔸-𝔹𝔻-𝔾𝕀-𝕄𝕆𝕊-𝕐𝕬-𝖅𝖠-𝖹𝗔-𝗭𝘈-𝘡𝘼-𝙕𝙰-𝚉𝚨-𝛀𝛢-𝛺𝜜-𝜴𝝖-𝝮𝞐-𝞨𝟊𞤀-𞤡@^_]"><token type="LiteralStringEscape"/><pop depth="1"/></rule>
<rule pattern="NUL|SOH|[SE]TX|EOT|ENQ|ACK|BEL|BS|HT|LF|VT|FF|CR|S[OI]|DLE|DC[1-4]|NAK|SYN|ETB|CAN|EM|SUB|ESC|[FGRU]S|SP|DEL"><token type="LiteralStringEscape"/><pop depth="1"/></rule>
<rule pattern="o[0-7]+"><token type="LiteralStringEscape"/><pop depth="1"/></rule>
<rule pattern="x[\da-fA-F]+"><token type="LiteralStringEscape"/><pop depth="1"/></rule>
<rule pattern="\d+"><token type="LiteralStringEscape"/><pop depth="1"/></rule>
<rule pattern="(\s+)(\\)"><bygroups><token type="TextWhitespace"/><token type="LiteralStringEscape"/></bygroups><pop depth="1"/></rule>
</state>
</rules>
</lexer>

View file

@ -0,0 +1,75 @@
<lexer>
<config>
<name>AL</name>
<alias>al</alias>
<filename>*.al</filename>
<filename>*.dal</filename>
<mime_type>text/x-al</mime_type>
<case_insensitive>true</case_insensitive>
<dot_all>true</dot_all>
</config>
<rules>
<state name="root">
<rule pattern="\s+">
<token type="TextWhitespace"/>
</rule>
<rule pattern="(?s)\/\*.*?\\*\*\/">
<token type="CommentMultiline"/>
</rule>
<rule pattern="(?s)//.*?\n">
<token type="CommentSingle"/>
</rule>
<rule pattern="\&#34;([^\&#34;])*\&#34;">
<token type="Text"/>
</rule>
<rule pattern="&#39;([^&#39;])*&#39;">
<token type="LiteralString"/>
</rule>
<rule pattern="\b(?i:(ARRAY|ASSERTERROR|BEGIN|BREAK|CASE|DO|DOWNTO|ELSE|END|EVENT|EXIT|FOR|FOREACH|FUNCTION|IF|IMPLEMENTS|IN|INDATASET|INTERFACE|INTERNAL|LOCAL|OF|PROCEDURE|PROGRAM|PROTECTED|REPEAT|RUNONCLIENT|SECURITYFILTERING|SUPPRESSDISPOSE|TEMPORARY|THEN|TO|TRIGGER|UNTIL|VAR|WHILE|WITH|WITHEVENTS))\b">
<token type="Keyword"/>
</rule>
<rule pattern="\b(?i:(AND|DIV|MOD|NOT|OR|XOR))\b">
<token type="OperatorWord"/>
</rule>
<rule pattern="\b(?i:(AVERAGE|CONST|COUNT|EXIST|FIELD|FILTER|LOOKUP|MAX|MIN|ORDER|SORTING|SUM|TABLEDATA|UPPERLIMIT|WHERE|ASCENDING|DESCENDING))\b">
<token type="Keyword"/>
</rule>
<rule pattern="\b(?i:(CODEUNIT|PAGE|PAGEEXTENSION|PAGECUSTOMIZATION|DOTNET|ENUM|ENUMEXTENSION|VALUE|QUERY|REPORT|TABLE|TABLEEXTENSION|XMLPORT|PROFILE|CONTROLADDIN|REPORTEXTENSION|INTERFACE|PERMISSIONSET|PERMISSIONSETEXTENSION|ENTITLEMENT))\b">
<token type="Keyword"/>
</rule>
<rule pattern="\b(?i:(Action|Array|Automation|BigInteger|BigText|Blob|Boolean|Byte|Char|ClientType|Code|Codeunit|CompletionTriggerErrorLevel|ConnectionType|Database|DataClassification|DataScope|Date|DateFormula|DateTime|Decimal|DefaultLayout|Dialog|Dictionary|DotNet|DotNetAssembly|DotNetTypeDeclaration|Duration|Enum|ErrorInfo|ErrorType|ExecutionContext|ExecutionMode|FieldClass|FieldRef|FieldType|File|FilterPageBuilder|Guid|InStream|Integer|Joker|KeyRef|List|ModuleDependencyInfo|ModuleInfo|None|Notification|NotificationScope|ObjectType|Option|OutStream|Page|PageResult|Query|Record|RecordId|RecordRef|Report|ReportFormat|SecurityFilter|SecurityFiltering|Table|TableConnectionType|TableFilter|TestAction|TestField|TestFilterField|TestPage|TestPermissions|TestRequestPage|Text|TextBuilder|TextConst|TextEncoding|Time|TransactionModel|TransactionType|Variant|Verbosity|Version|XmlPort|HttpContent|HttpHeaders|HttpClient|HttpRequestMessage|HttpResponseMessage|JsonToken|JsonValue|JsonArray|JsonObject|View|Views|XmlAttribute|XmlAttributeCollection|XmlComment|XmlCData|XmlDeclaration|XmlDocument|XmlDocumentType|XmlElement|XmlNamespaceManager|XmlNameTable|XmlNode|XmlNodeList|XmlProcessingInstruction|XmlReadOptions|XmlText|XmlWriteOptions|WebServiceActionContext|WebServiceActionResultCode|SessionSettings))\b">
<token type="Keyword"/>
</rule>
<rule pattern="\b([&lt;&gt;]=|&lt;&gt;|&lt;|&gt;)\b?">
<token type="Operator"/>
</rule>
<rule pattern="\b(\-|\+|\/|\*)\b">
<token type="Operator"/>
</rule>
<rule pattern="\s*(\:=|\+=|-=|\/=|\*=)\s*?">
<token type="Operator"/>
</rule>
<rule pattern="\b(?i:(ADD|ADDFIRST|ADDLAST|ADDAFTER|ADDBEFORE|ACTION|ACTIONS|AREA|ASSEMBLY|CHARTPART|CUEGROUP|CUSTOMIZES|COLUMN|DATAITEM|DATASET|ELEMENTS|EXTENDS|FIELD|FIELDGROUP|FIELDATTRIBUTE|FIELDELEMENT|FIELDGROUPS|FIELDS|FILTER|FIXED|GRID|GROUP|MOVEAFTER|MOVEBEFORE|KEY|KEYS|LABEL|LABELS|LAYOUT|MODIFY|MOVEFIRST|MOVELAST|MOVEBEFORE|MOVEAFTER|PART|REPEATER|USERCONTROL|REQUESTPAGE|SCHEMA|SEPARATOR|SYSTEMPART|TABLEELEMENT|TEXTATTRIBUTE|TEXTELEMENT|TYPE))\b">
<token type="Keyword"/>
</rule>
<rule pattern="\s*[(\.\.)&amp;\|]\s*">
<token type="Operator"/>
</rule>
<rule pattern="\b((0(x|X)[0-9a-fA-F]*)|(([0-9]+\.?[0-9]*)|(\.[0-9]+))((e|E)(\+|-)?[0-9]+)?)(L|l|UL|ul|u|U|F|f|ll|LL|ull|ULL)?\b">
<token type="LiteralNumber"/>
</rule>
<rule pattern="[;:,]">
<token type="Punctuation"/>
</rule>
<rule pattern="#[ \t]*(if|else|elif|endif|define|undef|region|endregion|pragma)\b.*?\n">
<token type="CommentPreproc"/>
</rule>
<rule pattern="\w+">
<token type="Text"/>
</rule>
<rule pattern=".">
<token type="Text"/>
</rule>
</state>
</rules>
</lexer>

View file

@ -0,0 +1,58 @@
<lexer>
<config>
<name>Alloy</name>
<alias>alloy</alias>
<filename>*.als</filename>
<mime_type>text/x-alloy</mime_type>
<dot_all>true</dot_all>
</config>
<rules>
<state name="sig">
<rule pattern="(extends)\b"><token type="Keyword"/><pop depth="1"/></rule>
<rule pattern="[a-zA-Z_][\w]*&quot;*"><token type="Name"/></rule>
<rule pattern="[^\S\n]+"><token type="TextWhitespace"/></rule>
<rule pattern=","><token type="Punctuation"/></rule>
<rule pattern="\{"><token type="Operator"/><pop depth="1"/></rule>
</state>
<state name="module">
<rule pattern="[^\S\n]+"><token type="TextWhitespace"/></rule>
<rule pattern="[a-zA-Z_][\w]*&quot;*"><token type="Name"/><pop depth="1"/></rule>
</state>
<state name="fun">
<rule pattern="[^\S\n]+"><token type="TextWhitespace"/></rule>
<rule pattern="\{"><token type="Operator"/><pop depth="1"/></rule>
<rule pattern="[a-zA-Z_][\w]*&quot;*"><token type="Name"/><pop depth="1"/></rule>
</state>
<state name="fact">
<rule><include state="fun"/></rule>
<rule pattern="&quot;\b(\\\\|\\[^\\]|[^&quot;\\])*&quot;"><token type="LiteralString"/><pop depth="1"/></rule>
</state>
<state name="root">
<rule pattern="--.*?$"><token type="CommentSingle"/></rule>
<rule pattern="//.*?$"><token type="CommentSingle"/></rule>
<rule pattern="/\*.*?\*/"><token type="CommentMultiline"/></rule>
<rule pattern="[^\S\n]+"><token type="TextWhitespace"/></rule>
<rule pattern="(module|open)(\s+)"><bygroups><token type="KeywordNamespace"/><token type="TextWhitespace"/></bygroups><push state="module"/></rule>
<rule pattern="(sig|enum)(\s+)"><bygroups><token type="KeywordDeclaration"/><token type="TextWhitespace"/></bygroups><push state="sig"/></rule>
<rule pattern="(iden|univ|none)\b"><token type="KeywordConstant"/></rule>
<rule pattern="(int|Int)\b"><token type="KeywordType"/></rule>
<rule pattern="(var|this|abstract|extends|set|seq|one|lone|let)\b"><token type="Keyword"/></rule>
<rule pattern="(all|some|no|sum|disj|when|else)\b"><token type="Keyword"/></rule>
<rule pattern="(run|check|for|but|exactly|expect|as|steps)\b"><token type="Keyword"/></rule>
<rule pattern="(always|after|eventually|until|release)\b"><token type="Keyword"/></rule>
<rule pattern="(historically|before|once|since|triggered)\b"><token type="Keyword"/></rule>
<rule pattern="(and|or|implies|iff|in)\b"><token type="OperatorWord"/></rule>
<rule pattern="(fun|pred|assert)(\s+)"><bygroups><token type="Keyword"/><token type="TextWhitespace"/></bygroups><push state="fun"/></rule>
<rule pattern="(fact)(\s+)"><bygroups><token type="Keyword"/><token type="TextWhitespace"/></bygroups><push state="fact"/></rule>
<rule pattern="!|#|&amp;&amp;|\+\+|&lt;&lt;|&gt;&gt;|&gt;=|&lt;=&gt;|&lt;=|\.\.|\.|-&gt;"><token type="Operator"/></rule>
<rule pattern="[-+/*%=&lt;&gt;&amp;!^|~{}\[\]().\&#x27;;]"><token type="Operator"/></rule>
<rule pattern="[a-zA-Z_][\w]*&quot;*"><token type="Name"/></rule>
<rule pattern="[:,]"><token type="Punctuation"/></rule>
<rule pattern="[0-9]+"><token type="LiteralNumberInteger"/></rule>
<rule pattern="&quot;\b(\\\\|\\[^\\]|[^&quot;\\])*&quot;"><token type="LiteralString"/></rule>
<rule pattern="\n"><token type="TextWhitespace"/></rule>
</state>
</rules>
</lexer>

View file

@ -0,0 +1,109 @@
<lexer>
<config>
<name>Angular2</name>
<alias>ng2</alias>
</config>
<rules>
<state name="attr">
<rule pattern="&#34;.*?&#34;">
<token type="LiteralString" />
<pop depth="1" />
</rule>
<rule pattern="&#39;.*?&#39;">
<token type="LiteralString" />
<pop depth="1" />
</rule>
<rule pattern="[^\s&gt;]+">
<token type="LiteralString" />
<pop depth="1" />
</rule>
</state>
<state name="root">
<rule pattern="[^{([*#]+">
<token type="Other" />
</rule>
<rule pattern="(\{\{)(\s*)">
<bygroups>
<token type="CommentPreproc" />
<token type="Text" />
</bygroups>
<push state="ngExpression" />
</rule>
<rule pattern="([([]+)([\w:.-]+)([\])]+)(\s*)(=)(\s*)">
<bygroups>
<token type="Punctuation" />
<token type="NameAttribute" />
<token type="Punctuation" />
<token type="Text" />
<token type="Operator" />
<token type="Text" />
</bygroups>
<push state="attr" />
</rule>
<rule pattern="([([]+)([\w:.-]+)([\])]+)(\s*)">
<bygroups>
<token type="Punctuation" />
<token type="NameAttribute" />
<token type="Punctuation" />
<token type="TextWhitespace" />
</bygroups>
</rule>
<rule pattern="([*#])([\w:.-]+)(\s*)(=)(\s*)">
<bygroups>
<token type="Punctuation" />
<token type="NameAttribute" />
<token type="Punctuation" />
<token type="Operator" />
<token type="TextWhitespace" />
</bygroups>
<push state="attr" />
</rule>
<rule pattern="([*#])([\w:.-]+)(\s*)">
<bygroups>
<token type="Punctuation" />
<token type="NameAttribute" />
<token type="Punctuation" />
</bygroups>
</rule>
</state>
<state name="ngExpression">
<rule pattern="\s+(\|\s+)?">
<token type="Text" />
</rule>
<rule pattern="\}\}">
<token type="CommentPreproc" />
<pop depth="1" />
</rule>
<rule pattern=":?(true|false)">
<token type="LiteralStringBoolean" />
</rule>
<rule pattern=":?&#34;(\\\\|\\&#34;|[^&#34;])*&#34;">
<token type="LiteralStringDouble" />
</rule>
<rule pattern=":?&#39;(\\\\|\\&#39;|[^&#39;])*&#39;">
<token type="LiteralStringSingle" />
</rule>
<rule pattern="[0-9](\.[0-9]*)?(eE[+-][0-9])?[flFLdD]?|0[xX][0-9a-fA-F]+[Ll]?">
<token type="LiteralNumber" />
</rule>
<rule pattern="[a-zA-Z][\w-]*(\(.*\))?">
<token type="NameVariable" />
</rule>
<rule pattern="\.[\w-]+(\(.*\))?">
<token type="NameVariable" />
</rule>
<rule pattern="(\?)(\s*)([^}\s]+)(\s*)(:)(\s*)([^}\s]+)(\s*)">
<bygroups>
<token type="Operator" />
<token type="Text" />
<token type="LiteralString" />
<token type="Text" />
<token type="Operator" />
<token type="Text" />
<token type="LiteralString" />
<token type="Text" />
</bygroups>
</rule>
</state>
</rules>
</lexer>

View file

@ -0,0 +1,317 @@
<lexer>
<config>
<name>ANTLR</name>
<alias>antlr</alias>
</config>
<rules>
<state name="nested-arg-action">
<rule pattern="([^$\[\]\&#39;&#34;/]+|&#34;(\\\\|\\&#34;|[^&#34;])*&#34;|&#39;(\\\\|\\&#39;|[^&#39;])*&#39;|//.*$\n?|/\*(.|\n)*?\*/|/(?!\*)(\\\\|\\/|[^/])*/|/)+">
<token type="Other"/>
</rule>
<rule pattern="\[">
<token type="Punctuation"/>
<push/>
</rule>
<rule pattern="\]">
<token type="Punctuation"/>
<pop depth="1"/>
</rule>
<rule pattern="(\$[a-zA-Z]+)(\.?)(text|value)?">
<bygroups>
<token type="NameVariable"/>
<token type="Punctuation"/>
<token type="NameProperty"/>
</bygroups>
</rule>
<rule pattern="(\\\\|\\\]|\\\[|[^\[\]])+">
<token type="Other"/>
</rule>
</state>
<state name="exception">
<rule pattern="\n">
<token type="TextWhitespace"/>
<pop depth="1"/>
</rule>
<rule pattern="\s">
<token type="TextWhitespace"/>
</rule>
<rule>
<include state="comments"/>
</rule>
<rule pattern="\[">
<token type="Punctuation"/>
<push state="nested-arg-action"/>
</rule>
<rule pattern="\{">
<token type="Punctuation"/>
<push state="action"/>
</rule>
</state>
<state name="whitespace">
<rule pattern="\s+">
<token type="TextWhitespace"/>
</rule>
</state>
<state name="root">
<rule>
<include state="whitespace"/>
</rule>
<rule>
<include state="comments"/>
</rule>
<rule pattern="(lexer|parser|tree)?(\s*)(grammar\b)(\s*)([A-Za-z]\w*)(;)">
<bygroups>
<token type="Keyword"/>
<token type="TextWhitespace"/>
<token type="Keyword"/>
<token type="TextWhitespace"/>
<token type="NameClass"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="options\b">
<token type="Keyword"/>
<push state="options"/>
</rule>
<rule pattern="tokens\b">
<token type="Keyword"/>
<push state="tokens"/>
</rule>
<rule pattern="(scope)(\s*)([A-Za-z]\w*)(\s*)(\{)">
<bygroups>
<token type="Keyword"/>
<token type="TextWhitespace"/>
<token type="NameVariable"/>
<token type="TextWhitespace"/>
<token type="Punctuation"/>
</bygroups>
<push state="action"/>
</rule>
<rule pattern="(catch|finally)\b">
<token type="Keyword"/>
<push state="exception"/>
</rule>
<rule pattern="(@[A-Za-z]\w*)(\s*)(::)?(\s*)([A-Za-z]\w*)(\s*)(\{)">
<bygroups>
<token type="NameLabel"/>
<token type="TextWhitespace"/>
<token type="Punctuation"/>
<token type="TextWhitespace"/>
<token type="NameLabel"/>
<token type="TextWhitespace"/>
<token type="Punctuation"/>
</bygroups>
<push state="action"/>
</rule>
<rule pattern="((?:protected|private|public|fragment)\b)?(\s*)([A-Za-z]\w*)(!)?">
<bygroups>
<token type="Keyword"/>
<token type="TextWhitespace"/>
<token type="NameLabel"/>
<token type="Punctuation"/>
</bygroups>
<push state="rule-alts" state="rule-prelims"/>
</rule>
</state>
<state name="tokens">
<rule>
<include state="whitespace"/>
</rule>
<rule>
<include state="comments"/>
</rule>
<rule pattern="\{">
<token type="Punctuation"/>
</rule>
<rule pattern="([A-Z]\w*)(\s*)(=)?(\s*)(\&#39;(?:\\\\|\\\&#39;|[^\&#39;]*)\&#39;)?(\s*)(;)">
<bygroups>
<token type="NameLabel"/>
<token type="TextWhitespace"/>
<token type="Punctuation"/>
<token type="TextWhitespace"/>
<token type="LiteralString"/>
<token type="TextWhitespace"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="\}">
<token type="Punctuation"/>
<pop depth="1"/>
</rule>
</state>
<state name="options">
<rule>
<include state="whitespace"/>
</rule>
<rule>
<include state="comments"/>
</rule>
<rule pattern="\{">
<token type="Punctuation"/>
</rule>
<rule pattern="([A-Za-z]\w*)(\s*)(=)(\s*)([A-Za-z]\w*|\&#39;(?:\\\\|\\\&#39;|[^\&#39;]*)\&#39;|[0-9]+|\*)(\s*)(;)">
<bygroups>
<token type="NameVariable"/>
<token type="TextWhitespace"/>
<token type="Punctuation"/>
<token type="TextWhitespace"/>
<token type="Text"/>
<token type="TextWhitespace"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="\}">
<token type="Punctuation"/>
<pop depth="1"/>
</rule>
</state>
<state name="rule-alts">
<rule>
<include state="whitespace"/>
</rule>
<rule>
<include state="comments"/>
</rule>
<rule pattern="options\b">
<token type="Keyword"/>
<push state="options"/>
</rule>
<rule pattern=":">
<token type="Punctuation"/>
</rule>
<rule pattern="&#39;(\\\\|\\&#39;|[^&#39;])*&#39;">
<token type="LiteralString"/>
</rule>
<rule pattern="&#34;(\\\\|\\&#34;|[^&#34;])*&#34;">
<token type="LiteralString"/>
</rule>
<rule pattern="&lt;&lt;([^&gt;]|&gt;[^&gt;])&gt;&gt;">
<token type="LiteralString"/>
</rule>
<rule pattern="\$?[A-Z_]\w*">
<token type="NameConstant"/>
</rule>
<rule pattern="\$?[a-z_]\w*">
<token type="NameVariable"/>
</rule>
<rule pattern="(\+|\||-&gt;|=&gt;|=|\(|\)|\.\.|\.|\?|\*|\^|!|\#|~)">
<token type="Operator"/>
</rule>
<rule pattern=",">
<token type="Punctuation"/>
</rule>
<rule pattern="\[">
<token type="Punctuation"/>
<push state="nested-arg-action"/>
</rule>
<rule pattern="\{">
<token type="Punctuation"/>
<push state="action"/>
</rule>
<rule pattern=";">
<token type="Punctuation"/>
<pop depth="1"/>
</rule>
</state>
<state name="rule-prelims">
<rule>
<include state="whitespace"/>
</rule>
<rule>
<include state="comments"/>
</rule>
<rule pattern="returns\b">
<token type="Keyword"/>
</rule>
<rule pattern="\[">
<token type="Punctuation"/>
<push state="nested-arg-action"/>
</rule>
<rule pattern="\{">
<token type="Punctuation"/>
<push state="action"/>
</rule>
<rule pattern="(throws)(\s+)([A-Za-z]\w*)">
<bygroups>
<token type="Keyword"/>
<token type="TextWhitespace"/>
<token type="NameLabel"/>
</bygroups>
</rule>
<rule pattern="(,)(\s*)([A-Za-z]\w*)">
<bygroups>
<token type="Punctuation"/>
<token type="TextWhitespace"/>
<token type="NameLabel"/>
</bygroups>
</rule>
<rule pattern="options\b">
<token type="Keyword"/>
<push state="options"/>
</rule>
<rule pattern="(scope)(\s+)(\{)">
<bygroups>
<token type="Keyword"/>
<token type="TextWhitespace"/>
<token type="Punctuation"/>
</bygroups>
<push state="action"/>
</rule>
<rule pattern="(scope)(\s+)([A-Za-z]\w*)(\s*)(;)">
<bygroups>
<token type="Keyword"/>
<token type="TextWhitespace"/>
<token type="NameLabel"/>
<token type="TextWhitespace"/>
<token type="Punctuation"/>
</bygroups>
</rule>
<rule pattern="(@[A-Za-z]\w*)(\s*)(\{)">
<bygroups>
<token type="NameLabel"/>
<token type="TextWhitespace"/>
<token type="Punctuation"/>
</bygroups>
<push state="action"/>
</rule>
<rule pattern=":">
<token type="Punctuation"/>
<pop depth="1"/>
</rule>
</state>
<state name="action">
<rule pattern="([^${}\&#39;&#34;/\\]+|&#34;(\\\\|\\&#34;|[^&#34;])*&#34;|&#39;(\\\\|\\&#39;|[^&#39;])*&#39;|//.*$\n?|/\*(.|\n)*?\*/|/(?!\*)(\\\\|\\/|[^/])*/|\\(?!%)|/)+">
<token type="Other"/>
</rule>
<rule pattern="(\\)(%)">
<bygroups>
<token type="Punctuation"/>
<token type="Other"/>
</bygroups>
</rule>
<rule pattern="(\$[a-zA-Z]+)(\.?)(text|value)?">
<bygroups>
<token type="NameVariable"/>
<token type="Punctuation"/>
<token type="NameProperty"/>
</bygroups>
</rule>
<rule pattern="\{">
<token type="Punctuation"/>
<push/>
</rule>
<rule pattern="\}">
<token type="Punctuation"/>
<pop depth="1"/>
</rule>
</state>
<state name="comments">
<rule pattern="//.*$">
<token type="Comment"/>
</rule>
<rule pattern="/\*(.|\n)*?\*/">
<token type="Comment"/>
</rule>
</state>
</rules>
</lexer>

View file

@ -0,0 +1,74 @@
<lexer>
<config>
<name>ApacheConf</name>
<alias>apacheconf</alias>
<alias>aconf</alias>
<alias>apache</alias>
<filename>.htaccess</filename>
<filename>apache.conf</filename>
<filename>apache2.conf</filename>
<mime_type>text/x-apacheconf</mime_type>
<case_insensitive>true</case_insensitive>
</config>
<rules>
<state name="root">
<rule pattern="\s+">
<token type="Text"/>
</rule>
<rule pattern="(#.*?)$">
<token type="Comment"/>
</rule>
<rule pattern="(&lt;[^\s&gt;]+)(?:(\s+)(.*?))?(&gt;)">
<bygroups>
<token type="NameTag"/>
<token type="Text"/>
<token type="LiteralString"/>
<token type="NameTag"/>
</bygroups>
</rule>
<rule pattern="([a-z]\w*)(\s+)">
<bygroups>
<token type="NameBuiltin"/>
<token type="Text"/>
</bygroups>
<push state="value"/>
</rule>
<rule pattern="\.+">
<token type="Text"/>
</rule>
</state>
<state name="value">
<rule pattern="\\\n">
<token type="Text"/>
</rule>
<rule pattern="$">
<token type="Text"/>
<pop depth="1"/>
</rule>
<rule pattern="\\">
<token type="Text"/>
</rule>
<rule pattern="[^\S\n]+">
<token type="Text"/>
</rule>
<rule pattern="\d+\.\d+\.\d+\.\d+(?:/\d+)?">
<token type="LiteralNumber"/>
</rule>
<rule pattern="\d+">
<token type="LiteralNumber"/>
</rule>
<rule pattern="/([a-z0-9][\w./-]+)">
<token type="LiteralStringOther"/>
</rule>
<rule pattern="(on|off|none|any|all|double|email|dns|min|minimal|os|productonly|full|emerg|alert|crit|error|warn|notice|info|debug|registry|script|inetd|standalone|user|group)\b">
<token type="Keyword"/>
</rule>
<rule pattern="&#34;([^&#34;\\]*(?:\\.[^&#34;\\]*)*)&#34;">
<token type="LiteralStringDouble"/>
</rule>
<rule pattern="[^\s&#34;\\]+">
<token type="Text"/>
</rule>
</state>
</rules>
</lexer>

View file

@ -0,0 +1,59 @@
<lexer>
<config>
<name>APL</name>
<alias>apl</alias>
<filename>*.apl</filename>
</config>
<rules>
<state name="root">
<rule pattern="\s+">
<token type="Text"/>
</rule>
<rule pattern="[⍝#].*$">
<token type="CommentSingle"/>
</rule>
<rule pattern="\&#39;((\&#39;\&#39;)|[^\&#39;])*\&#39;">
<token type="LiteralStringSingle"/>
</rule>
<rule pattern="&#34;((&#34;&#34;)|[^&#34;])*&#34;">
<token type="LiteralStringDouble"/>
</rule>
<rule pattern="[⋄◇()]">
<token type="Punctuation"/>
</rule>
<rule pattern="[\[\];]">
<token type="LiteralStringRegex"/>
</rule>
<rule pattern="⎕[A-Za-zΔ∆⍙][A-Za-zΔ∆⍙_¯0-9]*">
<token type="NameFunction"/>
</rule>
<rule pattern="[A-Za-zΔ∆⍙_][A-Za-zΔ∆⍙_¯0-9]*">
<token type="NameVariable"/>
</rule>
<rule pattern="¯?(0[Xx][0-9A-Fa-f]+|[0-9]*\.?[0-9]+([Ee][+¯]?[0-9]+)?|¯|∞)([Jj]¯?(0[Xx][0-9A-Fa-f]+|[0-9]*\.?[0-9]+([Ee][+¯]?[0-9]+)?|¯|∞))?">
<token type="LiteralNumber"/>
</rule>
<rule pattern="[\.\\/⌿⍀¨⍣⍨⍠⍤∘⍥@⌺⌶⍢]">
<token type="NameAttribute"/>
</rule>
<rule pattern="[+\-×÷⌈⌊∣|?*⍟○!⌹&lt;≤=&gt;≥≠≡≢∊⍷∪∩~∨∧⍱⍲⍴,⍪⌽⊖⍉↑↓⊂⊃⌷⍋⍒⊤⊥⍕⍎⊣⊢⍁⍂≈⌸⍯↗⊆⍸]">
<token type="Operator"/>
</rule>
<rule pattern="⍬">
<token type="NameConstant"/>
</rule>
<rule pattern="[⎕⍞]">
<token type="NameVariableGlobal"/>
</rule>
<rule pattern="[←→]">
<token type="KeywordDeclaration"/>
</rule>
<rule pattern="[⍺⍵⍶⍹∇:]">
<token type="NameBuiltinPseudo"/>
</rule>
<rule pattern="[{}]">
<token type="KeywordType"/>
</rule>
</state>
</rules>
</lexer>

View file

@ -0,0 +1,151 @@
<lexer>
<config>
<name>AppleScript</name>
<alias>applescript</alias>
<filename>*.applescript</filename>
<dot_all>true</dot_all>
</config>
<rules>
<state name="root">
<rule pattern="\s+">
<token type="Text" />
</rule>
<rule pattern="¬\n">
<token type="LiteralStringEscape" />
</rule>
<rule pattern="&#39;s\s+">
<token type="Text" />
</rule>
<rule pattern="(--|#).*?$">
<token type="Comment" />
</rule>
<rule pattern="\(\*">
<token type="CommentMultiline" />
<push state="comment" />
</rule>
<rule pattern="[(){}!,.:]">
<token type="Punctuation" />
</rule>
<rule pattern="(«)([^»]+)(»)">
<bygroups>
<token type="Text" />
<token type="NameBuiltin" />
<token type="Text" />
</bygroups>
</rule>
<rule
pattern="\b((?:considering|ignoring)\s*)(application responses|case|diacriticals|hyphens|numeric strings|punctuation|white space)"
>
<bygroups>
<token type="Keyword" />
<token type="NameBuiltin" />
</bygroups>
</rule>
<rule pattern="(-|\*|\+|&amp;|≠|&gt;=?|&lt;=?|=|≥|≤|/|÷|\^)">
<token type="Operator" />
</rule>
<rule
pattern="\b(and|or|is equal|equals|(is )?equal to|is not|isn&#39;t|isn&#39;t equal( to)?|is not equal( to)?|doesn&#39;t equal|does not equal|(is )?greater than|comes after|is not less than or equal( to)?|isn&#39;t less than or equal( to)?|(is )?less than|comes before|is not greater than or equal( to)?|isn&#39;t greater than or equal( to)?|(is )?greater than or equal( to)?|is not less than|isn&#39;t less than|does not come before|doesn&#39;t come before|(is )?less than or equal( to)?|is not greater than|isn&#39;t greater than|does not come after|doesn&#39;t come after|starts? with|begins? with|ends? with|contains?|does not contain|doesn&#39;t contain|is in|is contained by|is not in|is not contained by|isn&#39;t contained by|div|mod|not|(a )?(ref( to)?|reference to)|is|does)\b"
>
<token type="OperatorWord" />
</rule>
<rule
pattern="^(\s*(?:on|end)\s+)(zoomed|write to file|will zoom|will show|will select tab view item|will resize( sub views)?|will resign active|will quit|will pop up|will open|will move|will miniaturize|will hide|will finish launching|will display outline cell|will display item cell|will display cell|will display browser cell|will dismiss|will close|will become active|was miniaturized|was hidden|update toolbar item|update parameters|update menu item|shown|should zoom|should selection change|should select tab view item|should select row|should select item|should select column|should quit( after last window closed)?|should open( untitled)?|should expand item|should end editing|should collapse item|should close|should begin editing|selection changing|selection changed|selected tab view item|scroll wheel|rows changed|right mouse up|right mouse dragged|right mouse down|resized( sub views)?|resigned main|resigned key|resigned active|read from file|prepare table drop|prepare table drag|prepare outline drop|prepare outline drag|prepare drop|plugin loaded|parameters updated|panel ended|opened|open untitled|number of rows|number of items|number of browser rows|moved|mouse up|mouse moved|mouse exited|mouse entered|mouse dragged|mouse down|miniaturized|load data representation|launched|keyboard up|keyboard down|items changed|item value changed|item value|item expandable|idle|exposed|end editing|drop|drag( (entered|exited|updated))?|double clicked|document nib name|dialog ended|deminiaturized|data representation|conclude drop|column resized|column moved|column clicked|closed|clicked toolbar item|clicked|choose menu item|child of item|changed|change item value|change cell value|cell value changed|cell value|bounds changed|begin editing|became main|became key|awake from nib|alert ended|activated|action|accept table drop|accept outline drop)"
>
<token type="Keyword" />
</rule>
<rule pattern="^(\s*)(in|on|script|to)(\s+)">
<bygroups>
<token type="Text" />
<token type="Keyword" />
<token type="Text" />
</bygroups>
</rule>
<rule
pattern="\b(as )(alias |application |boolean |class |constant |date |file |integer |list |number |POSIX file |real |record |reference |RGB color |script |text |unit types|(?:Unicode )?text|string)\b"
>
<bygroups>
<token type="Keyword" />
<token type="NameClass" />
</bygroups>
</rule>
<rule
pattern="\b(AppleScript|current application|false|linefeed|missing value|pi|quote|result|return|space|tab|text item delimiters|true|version)\b"
>
<token type="NameConstant" />
</rule>
<rule
pattern="\b(ASCII (character|number)|activate|beep|choose URL|choose application|choose color|choose file( name)?|choose folder|choose from list|choose remote application|clipboard info|close( access)?|copy|count|current date|delay|delete|display (alert|dialog)|do shell script|duplicate|exists|get eof|get volume settings|info for|launch|list (disks|folder)|load script|log|make|mount volume|new|offset|open( (for access|location))?|path to|print|quit|random number|read|round|run( script)?|say|scripting components|set (eof|the clipboard to|volume)|store script|summarize|system attribute|system info|the clipboard|time to GMT|write|quoted form)\b"
>
<token type="NameBuiltin" />
</rule>
<rule
pattern="\b(considering|else|error|exit|from|if|ignoring|in|repeat|tell|then|times|to|try|until|using terms from|while|with|with timeout( of)?|with transaction|by|continue|end|its?|me|my|return|of|as)\b"
>
<token type="Keyword" />
</rule>
<rule pattern="\b(global|local|prop(erty)?|set|get)\b">
<token type="Keyword" />
</rule>
<rule pattern="\b(but|put|returning|the)\b">
<token type="NameBuiltin" />
</rule>
<rule pattern="\b(attachment|attribute run|character|day|month|paragraph|word|year)s?\b">
<token type="NameBuiltin" />
</rule>
<rule
pattern="\b(about|above|against|apart from|around|aside from|at|below|beneath|beside|between|for|given|instead of|on|onto|out of|over|since)\b"
>
<token type="NameBuiltin" />
</rule>
<rule
pattern="\b(accepts arrow key|action method|active|alignment|allowed identifiers|allows branch selection|allows column reordering|allows column resizing|allows column selection|allows customization|allows editing text attributes|allows empty selection|allows mixed state|allows multiple selection|allows reordering|allows undo|alpha( value)?|alternate image|alternate increment value|alternate title|animation delay|associated file name|associated object|auto completes|auto display|auto enables items|auto repeat|auto resizes( outline column)?|auto save expanded items|auto save name|auto save table columns|auto saves configuration|auto scroll|auto sizes all columns to fit|auto sizes cells|background color|bezel state|bezel style|bezeled|border rect|border type|bordered|bounds( rotation)?|box type|button returned|button type|can choose directories|can choose files|can draw|can hide|cell( (background color|size|type))?|characters|class|click count|clicked( data)? column|clicked data item|clicked( data)? row|closeable|collating|color( (mode|panel))|command key down|configuration|content(s| (size|view( margins)?))?|context|continuous|control key down|control size|control tint|control view|controller visible|coordinate system|copies( on scroll)?|corner view|current cell|current column|current( field)? editor|current( menu)? item|current row|current tab view item|data source|default identifiers|delta (x|y|z)|destination window|directory|display mode|displayed cell|document( (edited|rect|view))?|double value|dragged column|dragged distance|dragged items|draws( cell)? background|draws grid|dynamically scrolls|echos bullets|edge|editable|edited( data)? column|edited data item|edited( data)? row|enabled|enclosing scroll view|ending page|error handling|event number|event type|excluded from windows menu|executable path|expanded|fax number|field editor|file kind|file name|file type|first responder|first visible column|flipped|floating|font( panel)?|formatter|frameworks path|frontmost|gave up|grid color|has data items|has horizontal ruler|has horizontal scroller|has parent data item|has resize indicator|has shadow|has sub menu|has vertical ruler|has vertical scroller|header cell|header view|hidden|hides when deactivated|highlights by|horizontal line scroll|horizontal page scroll|horizontal ruler view|horizontally resizable|icon image|id|identifier|ignores multiple clicks|image( (alignment|dims when disabled|frame style|scaling))?|imports graphics|increment value|indentation per level|indeterminate|index|integer value|intercell spacing|item height|key( (code|equivalent( modifier)?|window))?|knob thickness|label|last( visible)? column|leading offset|leaf|level|line scroll|loaded|localized sort|location|loop mode|main( (bunde|menu|window))?|marker follows cell|matrix mode|maximum( content)? size|maximum visible columns|menu( form representation)?|miniaturizable|miniaturized|minimized image|minimized title|minimum column width|minimum( content)? size|modal|modified|mouse down state|movie( (controller|file|rect))?|muted|name|needs display|next state|next text|number of tick marks|only tick mark values|opaque|open panel|option key down|outline table column|page scroll|pages across|pages down|palette label|pane splitter|parent data item|parent window|pasteboard|path( (names|separator))?|playing|plays every frame|plays selection only|position|preferred edge|preferred type|pressure|previous text|prompt|properties|prototype cell|pulls down|rate|released when closed|repeated|requested print time|required file type|resizable|resized column|resource path|returns records|reuses columns|rich text|roll over|row height|rulers visible|save panel|scripts path|scrollable|selectable( identifiers)?|selected cell|selected( data)? columns?|selected data items?|selected( data)? rows?|selected item identifier|selection by rect|send action on arrow key|sends action when done editing|separates columns|separator item|sequence number|services menu|shared frameworks path|shared support path|sheet|shift key down|shows alpha|shows state by|size( mode)?|smart insert delete enabled|sort case sensitivity|sort column|sort order|sort type|sorted( data rows)?|sound|source( mask)?|spell checking enabled|starting page|state|string value|sub menu|super menu|super view|tab key traverses cells|tab state|tab type|tab view|table view|tag|target( printer)?|text color|text container insert|text container origin|text returned|tick mark position|time stamp|title(d| (cell|font|height|position|rect))?|tool tip|toolbar|trailing offset|transparent|treat packages as directories|truncated labels|types|unmodified characters|update views|use sort indicator|user defaults|uses data source|uses ruler|uses threaded animation|uses title from previous column|value wraps|version|vertical( (line scroll|page scroll|ruler view))?|vertically resizable|view|visible( document rect)?|volume|width|window|windows menu|wraps|zoomable|zoomed)\b"
>
<token type="NameAttribute" />
</rule>
<rule
pattern="\b(action cell|alert reply|application|box|browser( cell)?|bundle|button( cell)?|cell|clip view|color well|color-panel|combo box( item)?|control|data( (cell|column|item|row|source))?|default entry|dialog reply|document|drag info|drawer|event|font(-panel)?|formatter|image( (cell|view))?|matrix|menu( item)?|item|movie( view)?|open-panel|outline view|panel|pasteboard|plugin|popup button|progress indicator|responder|save-panel|scroll view|secure text field( cell)?|slider|sound|split view|stepper|tab view( item)?|table( (column|header cell|header view|view))|text( (field( cell)?|view))?|toolbar( item)?|user-defaults|view|window)s?\b"
>
<token type="NameBuiltin" />
</rule>
<rule
pattern="\b(animate|append|call method|center|close drawer|close panel|display|display alert|display dialog|display panel|go|hide|highlight|increment|item for|load image|load movie|load nib|load panel|load sound|localized string|lock focus|log|open drawer|path for|pause|perform action|play|register|resume|scroll|select( all)?|show|size to fit|start|step back|step forward|stop|synchronize|unlock focus|update)\b"
>
<token type="NameBuiltin" />
</rule>
<rule
pattern="\b((in )?back of|(in )?front of|[0-9]+(st|nd|rd|th)|first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth|after|back|before|behind|every|front|index|last|middle|some|that|through|thru|where|whose)\b"
>
<token type="NameBuiltin" />
</rule>
<rule pattern="&#34;(\\\\|\\&#34;|[^&#34;])*&#34;">
<token type="LiteralStringDouble" />
</rule>
<rule pattern="\b([a-zA-Z]\w*)\b">
<token type="NameVariable" />
</rule>
<rule pattern="[-+]?(\d+\.\d*|\d*\.\d+)(E[-+][0-9]+)?">
<token type="LiteralNumberFloat" />
</rule>
<rule pattern="[-+]?\d+">
<token type="LiteralNumberInteger" />
</rule>
</state>
<state name="comment">
<rule pattern="\(\*">
<token type="CommentMultiline" />
<push />
</rule>
<rule pattern="\*\)">
<token type="CommentMultiline" />
<pop depth="1" />
</rule>
<rule pattern="[^*(]+">
<token type="CommentMultiline" />
</rule>
<rule pattern="[*(]">
<token type="CommentMultiline" />
</rule>
</state>
</rules>
</lexer>

View file

@ -0,0 +1,174 @@
<lexer>
<config>
<name>ArangoDB AQL</name>
<alias>aql</alias>
<filename>*.aql</filename>
<mime_type>text/x-aql</mime_type>
<case_insensitive>true</case_insensitive>
<dot_all>true</dot_all>
<ensure_nl>true</ensure_nl>
</config>
<rules>
<state name="comments-and-whitespace">
<rule pattern="\s+">
<token type="Text"/>
</rule>
<rule pattern="//.*?\n">
<token type="CommentSingle"/>
</rule>
<rule pattern="/\*">
<token type="CommentMultiline"/>
<push state="multiline-comment"/>
</rule>
</state>
<state name="multiline-comment">
<rule pattern="[^*]+">
<token type="CommentMultiline"/>
</rule>
<rule pattern="\*/">
<token type="CommentMultiline"/>
<pop depth="1"/>
</rule>
<rule pattern="\*">
<token type="CommentMultiline"/>
</rule>
</state>
<state name="double-quote">
<rule pattern="\\.">
<token type="LiteralStringDouble"/>
</rule>
<rule pattern="[^&#34;\\]+">
<token type="LiteralStringDouble"/>
</rule>
<rule pattern="&#34;">
<token type="LiteralStringDouble"/>
<pop depth="1"/>
</rule>
</state>
<state name="single-quote">
<rule pattern="\\.">
<token type="LiteralStringSingle"/>
</rule>
<rule pattern="[^&#39;\\]+">
<token type="LiteralStringSingle"/>
</rule>
<rule pattern="&#39;">
<token type="LiteralStringSingle"/>
<pop depth="1"/>
</rule>
</state>
<state name="backtick">
<rule pattern="\\.">
<token type="Name"/>
</rule>
<rule pattern="[^`\\]+">
<token type="Name"/>
</rule>
<rule pattern="`">
<token type="Name"/>
<pop depth="1"/>
</rule>
</state>
<state name="forwardtick">
<rule pattern="\\.">
<token type="Name"/>
</rule>
<rule pattern="[^´\\]+">
<token type="Name"/>
</rule>
<rule pattern="´">
<token type="Name"/>
<pop depth="1"/>
</rule>
</state>
<state name="identifier">
<rule pattern="(?:\$?|_+)[a-z]+[_a-z0-9]*">
<token type="Name"/>
</rule>
<rule pattern="`">
<token type="Name"/>
<push state="backtick"/>
</rule>
<rule pattern="´">
<token type="Name"/>
<push state="forwardtick"/>
</rule>
</state>
<state name="root">
<rule>
<include state="comments-and-whitespace"/>
</rule>
<rule pattern="0b[01]+">
<token type="LiteralNumberBin"/>
</rule>
<rule pattern="0x[0-9a-f]+">
<token type="LiteralNumberHex"/>
</rule>
<rule pattern="(?:0|[1-9][0-9]*)(?![\.e])">
<token type="LiteralNumberInteger"/>
</rule>
<rule pattern="(?:(?:0|[1-9][0-9]*)(?:\.[0-9]+)?|\.[0-9]+)(?:e[\-\+]?[0-9]+)?">
<token type="LiteralNumberFloat"/>
</rule>
<rule pattern="@@(?:_+[a-z0-9]+[a-z0-9_]*|[a-z0-9][a-z0-9_]*)">
<token type="NameVariableGlobal"/>
</rule>
<rule pattern="@(?:_+[a-z0-9]+[a-z0-9_]*|[a-z0-9][a-z0-9_]*)">
<token type="NameVariable"/>
</rule>
<rule pattern="=~|!~|[=!&lt;&gt;]=?|[%?:/*+-]|\.\.|&amp;&amp;|\|\|">
<token type="Operator"/>
</rule>
<rule pattern="[.,(){}\[\]]">
<token type="Punctuation"/>
</rule>
<rule pattern="[a-zA-Z0-9][a-zA-Z0-9_]*(?:::[a-zA-Z0-9_]+)+(?=\s*\()">
<token type="NameFunction"/>
</rule>
<rule pattern="(WITH)(\s+)(COUNT)(\s+)(INTO)\b">
<bygroups>
<token type="KeywordReserved"/>
<token type="Text"/>
<token type="KeywordPseudo"/>
<token type="Text"/>
<token type="KeywordReserved"/>
</bygroups>
</rule>
<rule pattern="(?:KEEP|PRUNE|SEARCH|TO)\b">
<token type="KeywordPseudo"/>
</rule>
<rule pattern="OPTIONS(?=\s*\{)">
<token type="KeywordPseudo"/>
</rule>
<rule pattern="(?:AGGREGATE|ALL|ALL_SHORTEST_PATHS|AND|ANY|ASC|AT LEAST|COLLECT|DESC|DISTINCT|FILTER|FOR|GRAPH|IN|INBOUND|INSERT|INTO|K_PATHS|K_SHORTEST_PATHS|LIKE|LIMIT|NONE|NOT|OR|OUTBOUND|REMOVE|REPLACE|RETURN|SHORTEST_PATH|SORT|UPDATE|UPSERT|WITH|WINDOW)\b">
<token type="KeywordReserved"/>
</rule>
<rule pattern="LET\b">
<token type="KeywordDeclaration"/>
</rule>
<rule pattern="(?:true|false|null)\b">
<token type="KeywordConstant"/>
</rule>
<rule pattern="(?-i)(?:CURRENT|NEW|OLD)\b">
<token type="NameBuiltinPseudo"/>
</rule>
<rule pattern="(?:to_bool|to_number|to_char|to_string|to_array|to_list|is_null|is_bool|is_number|is_string|is_array|is_list|is_object|is_document|is_datestring|typename|json_stringify|json_parse|concat|concat_separator|char_length|lower|upper|substring|substring_bytes|left|right|trim|reverse|repeat|contains|log|log2|log10|exp|exp2|sin|cos|tan|asin|acos|atan|atan2|radians|degrees|pi|regex_test|regex_replace|like|floor|ceil|round|abs|rand|random|sqrt|pow|length|count|min|max|average|avg|sum|product|median|variance_population|variance_sample|variance|percentile|bit_and|bit_or|bit_xor|bit_negate|bit_test|bit_popcount|bit_shift_left|bit_shift_right|bit_construct|bit_deconstruct|bit_to_string|bit_from_string|first|last|unique|outersection|interleave|in_range|jaccard|matches|merge|merge_recursive|has|attributes|keys|values|entries|unset|unset_recursive|keep|keep_recursive|near|within|within_rectangle|is_in_polygon|distance|fulltext|stddev_sample|stddev_population|stddev|slice|nth|position|contains_array|translate|zip|call|apply|push|append|pop|shift|unshift|remove_value|remove_values|remove_nth|replace_nth|date_now|date_timestamp|date_iso8601|date_dayofweek|date_year|date_month|date_day|date_hour|date_minute|date_second|date_millisecond|date_dayofyear|date_isoweek|date_isoweekyear|date_leapyear|date_quarter|date_days_in_month|date_trunc|date_round|date_add|date_subtract|date_diff|date_compare|date_format|date_utctolocal|date_localtoutc|date_timezone|date_timezones|fail|passthru|v8|sleep|schema_get|schema_validate|shard_id|version|noopt|noeval|not_null|first_list|first_document|parse_identifier|parse_collection|parse_key|current_user|current_database|collection_count|pregel_result|collections|document|decode_rev|range|union|union_distinct|minus|intersection|flatten|is_same_collection|check_document|ltrim|rtrim|find_first|find_last|split|substitute|ipv4_to_number|ipv4_from_number|is_ipv4|md5|sha1|sha256|sha512|crc32|fnv64|hash|random_token|to_base64|to_hex|encode_uri_component|soundex|assert|warn|is_key|sorted|sorted_unique|count_distinct|count_unique|levenshtein_distance|levenshtein_match|regex_matches|regex_split|ngram_match|ngram_similarity|ngram_positional_similarity|uuid|tokens|exists|starts_with|phrase|min_match|bm25|tfidf|boost|analyzer|offset_info|value|cosine_similarity|decay_exp|decay_gauss|decay_linear|l1_distance|l2_distance|minhash|minhash_count|minhash_error|minhash_match|geo_point|geo_multipoint|geo_polygon|geo_multipolygon|geo_linestring|geo_multilinestring|geo_contains|geo_intersects|geo_equals|geo_distance|geo_area|geo_in_range)(?=\s*\()">
<token type="NameFunction"/>
</rule>
<rule pattern="&#34;">
<token type="LiteralStringDouble"/>
<push state="double-quote"/>
</rule>
<rule pattern="&#39;">
<token type="LiteralStringSingle"/>
<push state="single-quote"/>
</rule>
<rule pattern="#\d+\b">
<token type="NameLabel"/>
</rule>
<rule>
<include state="identifier"/>
</rule>
</state>
</rules>
</lexer>

View file

@ -0,0 +1,322 @@
<lexer>
<config>
<name>Arduino</name>
<alias>arduino</alias>
<filename>*.ino</filename>
<mime_type>text/x-arduino</mime_type>
<ensure_nl>true</ensure_nl>
</config>
<rules>
<state name="whitespace">
<rule pattern="^#if\s+0">
<token type="CommentPreproc" />
<push state="if0" />
</rule>
<rule pattern="^#">
<token type="CommentPreproc" />
<push state="macro" />
</rule>
<rule pattern="^(\s*(?:/[*].*?[*]/\s*)?)(#if\s+0)">
<bygroups>
<usingself state="root" />
<token type="CommentPreproc" />
</bygroups>
<push state="if0" />
</rule>
<rule pattern="^(\s*(?:/[*].*?[*]/\s*)?)(#)">
<bygroups>
<usingself state="root" />
<token type="CommentPreproc" />
</bygroups>
<push state="macro" />
</rule>
<rule pattern="\n">
<token type="Text" />
</rule>
<rule pattern="\s+">
<token type="Text" />
</rule>
<rule pattern="\\\n">
<token type="Text" />
</rule>
<rule pattern="//(\n|[\w\W]*?[^\\]\n)">
<token type="CommentSingle" />
</rule>
<rule pattern="/(\\\n)?[*][\w\W]*?[*](\\\n)?/">
<token type="CommentMultiline" />
</rule>
<rule pattern="/(\\\n)?[*][\w\W]*">
<token type="CommentMultiline" />
</rule>
</state>
<state name="string">
<rule pattern="&#34;">
<token type="LiteralString" />
<pop depth="1" />
</rule>
<rule pattern="\\([\\abfnrtv&#34;\&#39;]|x[a-fA-F0-9]{2,4}|u[a-fA-F0-9]{4}|U[a-fA-F0-9]{8}|[0-7]{1,3})">
<token type="LiteralStringEscape" />
</rule>
<rule pattern="[^\\&#34;\n]+">
<token type="LiteralString" />
</rule>
<rule pattern="\\\n">
<token type="LiteralString" />
</rule>
<rule pattern="\\">
<token type="LiteralString" />
</rule>
</state>
<state name="macro">
<rule pattern="(include)(\s*(?:/[*].*?[*]/\s*)?)([^\n]+)">
<bygroups>
<token type="CommentPreproc" />
<token type="Text" />
<token type="CommentPreprocFile" />
</bygroups>
</rule>
<rule pattern="[^/\n]+">
<token type="CommentPreproc" />
</rule>
<rule pattern="/[*](.|\n)*?[*]/">
<token type="CommentMultiline" />
</rule>
<rule pattern="//.*?\n">
<token type="CommentSingle" />
<pop depth="1" />
</rule>
<rule pattern="/">
<token type="CommentPreproc" />
</rule>
<rule pattern="(?&lt;=\\)\n">
<token type="CommentPreproc" />
</rule>
<rule pattern="\n">
<token type="CommentPreproc" />
<pop depth="1" />
</rule>
</state>
<state name="statements">
<rule
pattern="(reinterpret_cast|static_assert|dynamic_cast|thread_local|static_cast|const_cast|protected|constexpr|namespace|restrict|noexcept|override|operator|typename|template|explicit|decltype|nullptr|private|alignof|virtual|mutable|alignas|typeid|friend|throws|export|public|delete|final|using|throw|catch|this|try|new)\b"
>
<token type="Keyword" />
</rule>
<rule pattern="char(16_t|32_t)\b">
<token type="KeywordType" />
</rule>
<rule pattern="(class)\b">
<bygroups>
<token type="Keyword" />
</bygroups>
<push state="classname" />
</rule>
<rule pattern="(R)(&#34;)([^\\()\s]{,16})(\()((?:.|\n)*?)(\)\3)(&#34;)">
<bygroups>
<token type="LiteralStringAffix" />
<token type="LiteralString" />
<token type="LiteralStringDelimiter" />
<token type="LiteralStringDelimiter" />
<token type="LiteralString" />
<token type="LiteralStringDelimiter" />
<token type="LiteralString" />
</bygroups>
</rule>
<rule pattern="(u8|u|U)(&#34;)">
<bygroups>
<token type="LiteralStringAffix" />
<token type="LiteralString" />
</bygroups>
<push state="string" />
</rule>
<rule pattern="(L?)(&#34;)">
<bygroups>
<token type="LiteralStringAffix" />
<token type="LiteralString" />
</bygroups>
<push state="string" />
</rule>
<rule pattern="(L?)(&#39;)(\\.|\\[0-7]{1,3}|\\x[a-fA-F0-9]{1,2}|[^\\\&#39;\n])(&#39;)">
<bygroups>
<token type="LiteralStringAffix" />
<token type="LiteralStringChar" />
<token type="LiteralStringChar" />
<token type="LiteralStringChar" />
</bygroups>
</rule>
<rule pattern="(\d+\.\d*|\.\d+|\d+)[eE][+-]?\d+[LlUu]*">
<token type="LiteralNumberFloat" />
</rule>
<rule pattern="(\d+\.\d*|\.\d+|\d+[fF])[fF]?">
<token type="LiteralNumberFloat" />
</rule>
<rule pattern="0x[0-9a-fA-F]+[LlUu]*">
<token type="LiteralNumberHex" />
</rule>
<rule pattern="0[0-7]+[LlUu]*">
<token type="LiteralNumberOct" />
</rule>
<rule pattern="\d+[LlUu]*">
<token type="LiteralNumberInteger" />
</rule>
<rule pattern="\*/">
<token type="Error" />
</rule>
<rule pattern="[~!%^&amp;*+=|?:&lt;&gt;/-]">
<token type="Operator" />
</rule>
<rule pattern="[()\[\],.]">
<token type="Punctuation" />
</rule>
<rule
pattern="(restricted|volatile|continue|register|default|typedef|struct|extern|switch|sizeof|static|return|union|while|const|break|goto|enum|else|case|auto|for|asm|if|do)\b"
>
<token type="Keyword" />
</rule>
<rule
pattern="(_Bool|_Complex|_Imaginary|array|atomic_bool|atomic_char|atomic_int|atomic_llong|atomic_long|atomic_schar|atomic_short|atomic_uchar|atomic_uint|atomic_ullong|atomic_ulong|atomic_ushort|auto|bool|boolean|BooleanVariables|Byte|byte|Char|char|char16_t|char32_t|class|complex|Const|const|const_cast|delete|double|dynamic_cast|enum|explicit|extern|Float|float|friend|inline|Int|int|int16_t|int32_t|int64_t|int8_t|Long|long|new|NULL|null|operator|private|PROGMEM|protected|public|register|reinterpret_cast|short|signed|sizeof|Static|static|static_cast|String|struct|typedef|uint16_t|uint32_t|uint64_t|uint8_t|union|unsigned|virtual|Void|void|Volatile|volatile|word)\b"
>
<token type="KeywordType" />
</rule>
<rule pattern="(and|final|If|Loop|loop|not|or|override|setup|Setup|throw|try|xor)\b">
<token type="Keyword" />
</rule>
<rule
pattern="(ANALOG_MESSAGE|BIN|CHANGE|DEC|DEFAULT|DIGITAL_MESSAGE|EXTERNAL|FALLING|FIRMATA_STRING|HALF_PI|HEX|HIGH|INPUT|INPUT_PULLUP|INTERNAL|INTERNAL1V1|INTERNAL1V1|INTERNAL2V56|INTERNAL2V56|LED_BUILTIN|LED_BUILTIN_RX|LED_BUILTIN_TX|LOW|LSBFIRST|MSBFIRST|OCT|OUTPUT|PI|REPORT_ANALOG|REPORT_DIGITAL|RISING|SET_PIN_MODE|SYSEX_START|SYSTEM_RESET|TWO_PI)\b"
>
<token type="KeywordConstant" />
</rule>
<rule pattern="(boolean|const|byte|word|string|String|array)\b">
<token type="NameVariable" />
</rule>
<rule
pattern="(Keyboard|KeyboardController|MouseController|SoftwareSerial|EthernetServer|EthernetClient|LiquidCrystal|RobotControl|GSMVoiceCall|EthernetUDP|EsploraTFT|HttpClient|RobotMotor|WiFiClient|GSMScanner|FileSystem|Scheduler|GSMServer|YunClient|YunServer|IPAddress|GSMClient|GSMModem|Keyboard|Ethernet|Console|GSMBand|Esplora|Stepper|Process|WiFiUDP|GSM_SMS|Mailbox|USBHost|Firmata|PImage|Client|Server|GSMPIN|FileIO|Bridge|Serial|EEPROM|Stream|Mouse|Audio|Servo|File|Task|GPRS|WiFi|Wire|TFT|GSM|SPI|SD)\b"
>
<token type="NameClass" />
</rule>
<rule
pattern="(abs|Abs|accept|ACos|acos|acosf|addParameter|analogRead|AnalogRead|analogReadResolution|AnalogReadResolution|analogReference|AnalogReference|analogWrite|AnalogWrite|analogWriteResolution|AnalogWriteResolution|answerCall|asin|ASin|asinf|atan|ATan|atan2|ATan2|atan2f|atanf|attach|attached|attachGPRS|attachInterrupt|AttachInterrupt|autoscroll|available|availableForWrite|background|beep|begin|beginPacket|beginSD|beginSMS|beginSpeaker|beginTFT|beginTransmission|beginWrite|bit|Bit|BitClear|bitClear|bitRead|BitRead|bitSet|BitSet|BitWrite|bitWrite|blink|blinkVersion|BSSID|buffer|byte|cbrt|cbrtf|Ceil|ceil|ceilf|changePIN|char|charAt|checkPIN|checkPUK|checkReg|circle|cityNameRead|cityNameWrite|clear|clearScreen|click|close|compareTo|compassRead|concat|config|connect|connected|constrain|Constrain|copysign|copysignf|cos|Cos|cosf|cosh|coshf|countryNameRead|countryNameWrite|createChar|cursor|debugPrint|degrees|Delay|delay|DelayMicroseconds|delayMicroseconds|detach|DetachInterrupt|detachInterrupt|DigitalPinToInterrupt|digitalPinToInterrupt|DigitalRead|digitalRead|DigitalWrite|digitalWrite|disconnect|display|displayLogos|drawBMP|drawCompass|encryptionType|end|endPacket|endSMS|endsWith|endTransmission|endWrite|equals|equalsIgnoreCase|exists|exitValue|Exp|exp|expf|fabs|fabsf|fdim|fdimf|fill|find|findUntil|float|floor|Floor|floorf|flush|fma|fmaf|fmax|fmaxf|fmin|fminf|fmod|fmodf|gatewayIP|get|getAsynchronously|getBand|getButton|getBytes|getCurrentCarrier|getIMEI|getKey|getModifiers|getOemKey|getPINUsed|getResult|getSignalStrength|getSocket|getVoiceCallStatus|getXChange|getYChange|hangCall|height|highByte|HighByte|home|hypot|hypotf|image|indexOf|int|interrupts|IPAddress|IRread|isActionDone|isAlpha|isAlphaNumeric|isAscii|isControl|isDigit|isDirectory|isfinite|isGraph|isHexadecimalDigit|isinf|isListening|isLowerCase|isnan|isPIN|isPressed|isPrintable|isPunct|isSpace|isUpperCase|isValid|isWhitespace|keyboardRead|keyPressed|keyReleased|knobRead|lastIndexOf|ldexp|ldexpf|leftToRight|length|line|lineFollowConfig|listen|listenOnLocalhost|loadImage|localIP|log|Log|log10|log10f|logf|long|lowByte|LowByte|lrint|lrintf|lround|lroundf|macAddress|maintain|map|Map|Max|max|messageAvailable|Micros|micros|millis|Millis|Min|min|mkdir|motorsStop|motorsWrite|mouseDragged|mouseMoved|mousePressed|mouseReleased|move|noAutoscroll|noBlink|noBuffer|noCursor|noDisplay|noFill|noInterrupts|NoInterrupts|noListenOnLocalhost|noStroke|noTone|NoTone|onReceive|onRequest|open|openNextFile|overflow|parseCommand|parseFloat|parseInt|parsePacket|pauseMode|peek|PinMode|pinMode|playFile|playMelody|point|pointTo|position|Pow|pow|powf|prepare|press|print|printFirmwareVersion|println|printVersion|process|processInput|PulseIn|pulseIn|pulseInLong|PulseInLong|put|radians|random|Random|randomSeed|RandomSeed|read|readAccelerometer|readBlue|readButton|readBytes|readBytesUntil|readGreen|readJoystickButton|readJoystickSwitch|readJoystickX|readJoystickY|readLightSensor|readMessage|readMicrophone|readNetworks|readRed|readSlider|readString|readStringUntil|readTemperature|ready|rect|release|releaseAll|remoteIP|remoteNumber|remotePort|remove|replace|requestFrom|retrieveCallingNumber|rewindDirectory|rightToLeft|rmdir|robotNameRead|robotNameWrite|round|roundf|RSSI|run|runAsynchronously|running|runShellCommand|runShellCommandAsynchronously|scanNetworks|scrollDisplayLeft|scrollDisplayRight|seek|sendAnalog|sendDigitalPortPair|sendDigitalPorts|sendString|sendSysex|Serial_Available|Serial_Begin|Serial_End|Serial_Flush|Serial_Peek|Serial_Print|Serial_Println|Serial_Read|serialEvent|setBand|setBitOrder|setCharAt|setClockDivider|setCursor|setDataMode|setDNS|setFirmwareVersion|setMode|setPINUsed|setSpeed|setTextSize|setTimeout|ShiftIn|shiftIn|ShiftOut|shiftOut|shutdown|signbit|sin|Sin|sinf|sinh|sinhf|size|sizeof|Sq|sq|Sqrt|sqrt|sqrtf|SSID|startLoop|startsWith|step|stop|stroke|subnetMask|substring|switchPIN|tan|Tan|tanf|tanh|tanhf|tempoWrite|text|toCharArray|toInt|toLowerCase|tone|Tone|toUpperCase|transfer|trim|trunc|truncf|tuneWrite|turn|updateIR|userNameRead|userNameWrite|voiceCall|waitContinue|width|WiFiServer|word|write|writeBlue|writeGreen|writeJSON|writeMessage|writeMicroseconds|writeRed|writeRGB|yield|Yield)\b"
>
<token type="NameFunction" />
</rule>
<rule pattern="(typename|__inline|restrict|_inline|thread|inline|naked)\b">
<token type="KeywordReserved" />
</rule>
<rule pattern="(__m(128i|128d|128|64))\b">
<token type="KeywordReserved" />
</rule>
<rule
pattern="__(forceinline|identifier|unaligned|declspec|fastcall|finally|stdcall|wchar_t|assume|except|int32|cdecl|int16|leave|based|raise|int64|noop|int8|w64|try|asm)\b"
>
<token type="KeywordReserved" />
</rule>
<rule pattern="(true|false|NULL)\b">
<token type="NameBuiltin" />
</rule>
<rule pattern="([a-zA-Z_]\w*)(\s*)(:)(?!:)">
<bygroups>
<token type="NameLabel" />
<token type="Text" />
<token type="Punctuation" />
</bygroups>
</rule>
<rule pattern="[a-zA-Z_]\w*">
<token type="Name" />
</rule>
</state>
<state name="function">
<rule>
<include state="whitespace" />
</rule>
<rule>
<include state="statements" />
</rule>
<rule pattern=";">
<token type="Punctuation" />
</rule>
<rule pattern="\{">
<token type="Punctuation" />
<push />
</rule>
<rule pattern="\}">
<token type="Punctuation" />
<pop depth="1" />
</rule>
</state>
<state name="if0">
<rule pattern="^\s*#if.*?(?&lt;!\\)\n">
<token type="CommentPreproc" />
<push />
</rule>
<rule pattern="^\s*#el(?:se|if).*\n">
<token type="CommentPreproc" />
<pop depth="1" />
</rule>
<rule pattern="^\s*#endif.*?(?&lt;!\\)\n">
<token type="CommentPreproc" />
<pop depth="1" />
</rule>
<rule pattern=".*?\n">
<token type="Comment" />
</rule>
</state>
<state name="classname">
<rule pattern="[a-zA-Z_]\w*">
<token type="NameClass" />
<pop depth="1" />
</rule>
<rule pattern="\s*(?=&gt;)">
<token type="Text" />
<pop depth="1" />
</rule>
</state>
<state name="statement">
<rule>
<include state="whitespace" />
</rule>
<rule>
<include state="statements" />
</rule>
<rule pattern="[{}]">
<token type="Punctuation" />
</rule>
<rule pattern=";">
<token type="Punctuation" />
<pop depth="1" />
</rule>
</state>
<state name="root">
<rule>
<include state="whitespace" />
</rule>
<rule pattern="((?:[\w*\s])+?(?:\s|[*]))([a-zA-Z_]\w*)(\s*\([^;]*?\))([^;{]*)(\{)">
<bygroups>
<usingself state="root" />
<token type="NameFunction" />
<usingself state="root" />
<usingself state="root" />
<token type="Punctuation" />
</bygroups>
<push state="function" />
</rule>
<rule pattern="((?:[\w*\s])+?(?:\s|[*]))([a-zA-Z_]\w*)(\s*\([^;]*?\))([^;]*)(;)">
<bygroups>
<usingself state="root" />
<token type="NameFunction" />
<usingself state="root" />
<usingself state="root" />
<token type="Punctuation" />
</bygroups>
</rule>
<rule>
<push state="statement" />
</rule>
<rule pattern="__(multiple_inheritance|virtual_inheritance|single_inheritance|interface|uuidof|super|event)\b">
<token type="KeywordReserved" />
</rule>
<rule pattern="__(offload|blockingoffload|outer)\b">
<token type="KeywordPseudo" />
</rule>
</state>
</rules>
</lexer>

Some files were not shown because too many files have changed in this diff Show more