289 lines
5.1 KiB
Go
289 lines
5.1 KiB
Go
package twwidth
|
||
|
||
import (
|
||
"bufio"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
)
|
||
|
||
type Tab rune
|
||
|
||
const (
|
||
TabWidthDefault = 8
|
||
TabString Tab = '\t'
|
||
)
|
||
|
||
// IsTab returns true if t equals the default tab.
|
||
func (t Tab) IsTab() bool {
|
||
return t == TabString
|
||
}
|
||
|
||
func (t Tab) Byte() byte {
|
||
return byte(t)
|
||
}
|
||
|
||
func (t Tab) Rune() rune {
|
||
return rune(t)
|
||
}
|
||
|
||
func (t Tab) String() string {
|
||
return string(t)
|
||
}
|
||
|
||
// IsTab returns true if r is a tab rune.
|
||
func IsTab(r rune) bool {
|
||
return r == TabString.Rune()
|
||
}
|
||
|
||
type Tabinal struct {
|
||
once sync.Once
|
||
width int
|
||
mu sync.RWMutex
|
||
}
|
||
|
||
func (t *Tabinal) String() string {
|
||
return TabString.String()
|
||
}
|
||
|
||
// Size returns the current tab width, default if unset.
|
||
func (t *Tabinal) Size() int {
|
||
t.once.Do(t.init)
|
||
|
||
t.mu.RLock()
|
||
w := t.width
|
||
t.mu.RUnlock()
|
||
|
||
if w <= 0 {
|
||
return TabWidthDefault
|
||
}
|
||
return w
|
||
}
|
||
|
||
// SetWidth sets the tab width if valid (1–32).
|
||
func (t *Tabinal) SetWidth(w int) {
|
||
if w <= 0 || w > 32 {
|
||
return
|
||
}
|
||
t.mu.Lock()
|
||
t.width = w
|
||
t.mu.Unlock()
|
||
}
|
||
|
||
func (t *Tabinal) init() {
|
||
w := t.detect()
|
||
t.mu.Lock()
|
||
t.width = w
|
||
t.mu.Unlock()
|
||
}
|
||
|
||
// detect determines tab width using env, editorconfig, project, or term.
|
||
func (t *Tabinal) detect() int {
|
||
if w := envInt("TABWIDTH"); w > 0 {
|
||
return clamp(w)
|
||
}
|
||
if w := envInt("TS"); w > 0 {
|
||
return clamp(w)
|
||
}
|
||
if w := envInt("VIM_TABSTOP"); w > 0 {
|
||
return clamp(w)
|
||
}
|
||
if w := editorConfigTabWidth(); w > 0 {
|
||
return w
|
||
}
|
||
if w := projectHeuristic(); w > 0 {
|
||
return w
|
||
}
|
||
if w := termHeuristic(); w > 0 {
|
||
return w
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func editorConfigTabWidth() int {
|
||
dir, err := os.Getwd()
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
|
||
for dir != "" && dir != "/" && dir != "." {
|
||
path := filepath.Join(dir, ".editorconfig")
|
||
if w := parseEditorConfig(path); w > 0 {
|
||
return clamp(w)
|
||
}
|
||
parent := filepath.Dir(dir)
|
||
if parent == dir {
|
||
break
|
||
}
|
||
dir = parent
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// parseEditorConfig reads tab_width or indent_size from a file.
|
||
func parseEditorConfig(path string) int {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
defer f.Close()
|
||
|
||
scanner := bufio.NewScanner(f)
|
||
inMatch := false
|
||
globalWidth := 0
|
||
|
||
for scanner.Scan() {
|
||
line := strings.TrimSpace(scanner.Text())
|
||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||
continue
|
||
}
|
||
|
||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||
pattern := line[1 : len(line)-1]
|
||
inMatch = pattern == "*"
|
||
|
||
knownExts := []string{".go", ".py", ".js", ".ts", ".java", ".rs"}
|
||
for _, ext := range knownExts {
|
||
if strings.Contains(pattern, ext) {
|
||
inMatch = true
|
||
break
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
|
||
if !inMatch && globalWidth == 0 {
|
||
continue
|
||
}
|
||
|
||
parts := strings.SplitN(line, "=", 2)
|
||
if len(parts) != 2 {
|
||
continue
|
||
}
|
||
key := strings.TrimSpace(parts[0])
|
||
val := strings.TrimSpace(parts[1])
|
||
|
||
switch key {
|
||
case "tab_width":
|
||
if w, err := strconv.Atoi(val); err == nil && w > 0 {
|
||
if inMatch {
|
||
return clamp(w)
|
||
}
|
||
if globalWidth == 0 {
|
||
globalWidth = w
|
||
}
|
||
}
|
||
case "indent_size":
|
||
if val == "tab" {
|
||
continue
|
||
}
|
||
if w, err := strconv.Atoi(val); err == nil && w > 0 {
|
||
if inMatch {
|
||
return clamp(w)
|
||
}
|
||
if globalWidth == 0 {
|
||
globalWidth = w
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return globalWidth
|
||
}
|
||
|
||
// projectHeuristic returns 4 for known project types.
|
||
func projectHeuristic() int {
|
||
dir, err := os.Getwd()
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
|
||
indicators := []string{
|
||
"go.mod", "go.sum",
|
||
"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
||
"setup.py", "pyproject.toml", "requirements.txt", "Pipfile",
|
||
"pom.xml", "build.gradle", "build.gradle.kts",
|
||
"Cargo.toml",
|
||
"composer.json",
|
||
}
|
||
|
||
for _, indicator := range indicators {
|
||
if _, err := os.Stat(filepath.Join(dir, indicator)); err == nil {
|
||
return 4
|
||
}
|
||
}
|
||
|
||
patterns := []string{"*.go", "*.py", "*.js", "*.ts", "*.java", "*.rs"}
|
||
for _, pattern := range patterns {
|
||
if matches, _ := filepath.Glob(filepath.Join(dir, pattern)); len(matches) > 0 {
|
||
return 4
|
||
}
|
||
}
|
||
|
||
return 0
|
||
}
|
||
|
||
// termHeuristic returns a default width based on the TERM variable.
|
||
func termHeuristic() int {
|
||
termEnv := strings.ToLower(os.Getenv("TERM"))
|
||
if termEnv == "" {
|
||
return 0
|
||
}
|
||
|
||
if strings.Contains(termEnv, "vt52") {
|
||
return 2
|
||
}
|
||
|
||
if strings.Contains(termEnv, "xterm") ||
|
||
strings.Contains(termEnv, "screen") ||
|
||
strings.Contains(termEnv, "tmux") ||
|
||
strings.Contains(termEnv, "linux") ||
|
||
strings.Contains(termEnv, "ansi") ||
|
||
strings.Contains(termEnv, "rxvt") {
|
||
return TabWidthDefault
|
||
}
|
||
|
||
return 0
|
||
}
|
||
|
||
func clamp(w int) int {
|
||
if w <= 0 {
|
||
return 0
|
||
}
|
||
if w > 32 {
|
||
return 32
|
||
}
|
||
return w
|
||
}
|
||
|
||
var (
|
||
globalTab *Tabinal
|
||
globalTabOnce sync.Once
|
||
)
|
||
|
||
// TabInstance returns the singleton Tabinal.
|
||
func TabInstance() *Tabinal {
|
||
globalTabOnce.Do(func() {
|
||
globalTab = &Tabinal{}
|
||
})
|
||
return globalTab
|
||
}
|
||
|
||
// TabWidth returns the detected global tab width.
|
||
func TabWidth() int {
|
||
return TabInstance().Size()
|
||
}
|
||
|
||
// SetTabWidth sets the global tab width.
|
||
func SetTabWidth(w int) {
|
||
TabInstance().SetWidth(w)
|
||
}
|
||
|
||
func envInt(k string) int {
|
||
v := os.Getenv(k)
|
||
w, _ := strconv.Atoi(v)
|
||
return w
|
||
}
|