// Cross-platform helpers that don't depend on platform-specific
// rules. Platform-specific logic (drive letters, UNC roots, root-
// relative, drive-relative rejection) lives in path_unix.go and
// path_windows.go. This file holds:
//
// - IME / CJK input normalization (mirrors cwd/normalize.go's
// contract — kept here for callers that want path-shape cleanup
// without the cwd-specific HOME-relative semantics)
// - IsAbs / Join / Clean / Base / Dir: thin wrappers over the
// stdlib's filepath package that exist for the same reason
// FromSlash / ToSlash do — so grep `pathutil.Clean` finds every
// caller and the "must use pathutil" rule (SPEC.md §13.3.1)
// has zero exceptions.
//
// See F-PATHUTIL-001 §13.3.1 for the full mandatory-use table.
package pathutil
import (
"path/filepath"
"regexp"
"strings"
)
// htmlLinkTag mirrors cwd/normalize.go::htmlLinkTag — kept in sync
// because the same IM-emitted markup pollutes path arguments
// everywhere users can paste a path (cwd, gtw fix, future
// attachment upload commands, etc.). Sharing the regex across
// packages would require either an awkward import or an
// extract-to-string-constant dance, so we duplicate it. The two
// copies drift if either side's chat-client list changes — bump
// both when adding a new IM markup pattern.
var htmlLinkTag = regexp.MustCompile(`(?i)<a\b[^>]*href=["']([^"']*)["'][^>]*>(.*?)</a>`)
// NormalizeIMRichText strips IM-emitted rich-text markup that
// pollutes path arguments:
//
// 1. <a ...>visible text</a> — when the user pastes a URL into
// chat (feishu / lark / slack / teams all do this). Inner
// text is what the user saw; we keep it.
// 2. Bare URLs (no link-card wrapper): passed through unchanged
// here; URL-vs-path disambiguation is the resolver's job.
//
// Multiple <a> tags concatenate inner text in source order.
// Nested tags (rare) are not handled — the outer <a> is stripped
// and inner tag literals pass through; the path resolver will
// reject them with a clearer message than this regex would.
//
// Returns the cleaned string. Always returns non-empty when input
// was non-empty (the regex fallback uses href as inner text when
// inner is empty).
func NormalizeIMRichText(s string) string {
return htmlLinkTag.ReplaceAllStringFunc(s, func(match string) string {
sm := htmlLinkTag.FindStringSubmatch(match)
if len(sm) < 3 {
return ""
}
inner := strings.TrimSpace(sm[2])
if inner == "" {
inner = strings.Trim(sm[1], `"'`)
}
return inner
})
}
// NormalizeInput runs NormalizeIMRichText + full-width-ASCII →
// half-width + CJK punctuation → English. Mirrors cwd/normalize.go
// ::normalizePathInput's contract (see that file for the
// full mapping table); duplicated here so pathutil callers can
// normalize without taking a dependency on the cwd package.
//
// This is "shape" normalization — it doesn't decide whether the
// string is an absolute path or a $HOME-relative one. That's
// NormalizeForOS / cwd::resolvePath's job.
func NormalizeInput(s string) string {
s = NormalizeIMRichText(s)
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch {
case r >= 0xFF01 && r <= 0xFF5E:
b.WriteRune(r - 0xFEE0) // full-width ASCII → half-width
case r == ' ':
b.WriteRune(' ') // full-width space
case r == '。':
b.WriteRune('.')
case r == ',', r == '、':
b.WriteRune(',')
case r == ';':
b.WriteRune(';')
case r == ':':
b.WriteRune(':')
case r == '?':
b.WriteRune('?')
case r == '!':
b.WriteRune('!')
case r == '“', r == '”':
b.WriteRune('"')
case r == '‘', r == '’':
b.WriteRune('\'')
case r == '(':
b.WriteRune('(')
case r == ')':
b.WriteRune(')')
case r == '《':
b.WriteRune('<')
case r == '》':
b.WriteRune('>')
case r == '【':
b.WriteRune('[')
case r == '】':
b.WriteRune(']')
case r == '—':
b.WriteRune('-')
case r == '…':
b.WriteString("...")
default:
b.WriteRune(r)
}
}
return b.String()
}
// Clean is a thin wrapper over filepath.Clean. See F-PATHUTIL-001
// §13.3.1 — every site that wants "cleaned path" must call
// pathutil.Clean, not filepath.Clean, so the platform-specific
// helpers can grow additional rules (long-path handling, UNC
// canonicalization, etc.) without touching every call site.
func Clean(p string) string { return filepath.Clean(p) }
// Join is a thin wrapper over filepath.Join. Same rationale as
// Clean: one import point so the centralized rule has zero
// exceptions.
func Join(elem ...string) string { return filepath.Join(elem...) }
// IsAbs is a thin wrapper over filepath.IsAbs. Same rationale.
// On Windows, returns true for "C:\foo", "C:/foo", "\\foo", "\foo"
// (after Clean), but false for "C:foo" (drive-relative). Use
// NormalizeForOS when you want to ALSO canonicalize the form.
func IsAbs(p string) bool { return filepath.IsAbs(p) }
// Base is a thin wrapper over filepath.Base.
func Base(p string) string { return filepath.Base(p) }
// Dir is a thin wrapper over filepath.Dir.
func Dir(p string) string { return filepath.Dir(p) }