Browse Source

happy parser

wip
AJ ONeal 5 years ago
parent
commit
d850aff673
  1. 97
      envpath/parse.go
  2. 56
      envpath/parse_test.go
  3. 5
      go.mod
  4. 229
      pathman.go
  5. 23
      pathman_unixes.go
  6. 23
      pathman_windows.go
  7. 63
      winpath/README.md
  8. 16
      winpath/native_test.go
  9. 94
      winpath/winpath.go
  10. 15
      winpath/winpath_nonwindows.go
  11. 23
      winpath/winpath_test.go
  12. 32
      winpath/winpath_unsafe.go
  13. 120
      winpath/winpath_windows.go

97
envpath/parse.go

@ -0,0 +1,97 @@
package envpath
import (
"fmt"
"strings"
)
type Warning struct {
LineNumber int
Line string
Message string
}
// Parse will return a list of paths from an export file
func Parse(envname string, b []byte) ([]string, []Warning) {
s := string(b)
s = strings.Replace(s, "\r\n", "\n", -1)
badlines := []Warning{}
newlines := []string{}
entries := make(map[string]bool)
lines := strings.Split(s, "\n")
for i := range lines {
line := strings.TrimPrefix(strings.TrimSpace(lines[i]), "export ")
if "" == line {
continue
}
if "# Generated for envman. Do not edit." == line {
continue
}
if '#' == line[0] {
badlines = append(badlines, Warning{
LineNumber: i,
Line: line,
Message: "comment",
})
continue
}
index := strings.Index(line, "=")
if index < 1 {
badlines = append(badlines, Warning{
LineNumber: i,
Line: line,
Message: "invalid assignment",
})
continue
}
env := line[:index]
if env != envname {
badlines = append(badlines, Warning{
LineNumber: i,
Line: line,
Message: fmt.Sprintf("wrong name (%s != %s)", env, envname),
})
continue
}
val := line[index+1:]
if len(val) < 2 || '"' != val[0] || '"' != val[len(val)-1] {
badlines = append(badlines, Warning{
LineNumber: i,
Line: line,
Message: "value not quoted",
})
continue
}
val = val[1 : len(val)-1]
if strings.Contains(val, `"`) {
badlines = append(badlines, Warning{
LineNumber: i,
Line: line,
Message: "invalid quotes",
})
continue
}
// TODO normalize $HOME
if entries[val] {
badlines = append(badlines, Warning{
LineNumber: i,
Line: line,
Message: "duplicate entry",
})
continue
}
entries[val] = true
newlines = append(newlines, val)
}
return newlines, badlines
}

56
envpath/parse_test.go

@ -0,0 +1,56 @@
package envpath
import (
"fmt"
"strings"
"testing"
)
const file = `# Generated for envman. Do not edit.
PATH="/foo"
# ignore
# ignore
PATH="/foo"
PATH="/foo:$PATH"
PATH="/foo:$PATH"
PATH="/foo:"$PATH"
PATH="/foo:""$PATH"
PATH=""
PATH=
JUNK=""
JUNK=
=""
=
whatever
PATH="/boo:$PATH"
PATH=""
`
var paths = []string{
`PATH="/foo"`,
`PATH="/foo:$PATH"`,
`PATH=""`,
`PATH="/boo:$PATH"`,
}
func TestParse(t *testing.T) {
newlines, warnings := Parse("PATH", []byte(file))
newfile := `PATH="` + strings.Join(newlines, "\"\n\tPATH=\"") + `"`
expfile := strings.Join(paths, "\n\t")
if newfile != expfile {
t.Errorf("\nExpected:\n\t%s\nGot:\n\t%s", expfile, newfile)
}
for i := range warnings {
w := warnings[i]
fmt.Printf("warning dropping %q from line %d: %s\n", w.Message, w.LineNumber, w.Line)
}
}

5
go.mod

@ -0,0 +1,5 @@
module git.rootprojects.org/root/pathman
go 1.12
require golang.org/x/sys v0.0.0-20190712062909-fae7ac547cb7

229
pathman.go

@ -0,0 +1,229 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
)
func usage() {
fmt.Fprintf(os.Stdout, "Usage: envpath <action> [path]\n")
fmt.Fprintf(os.Stdout, "\tex: envpath list\n")
fmt.Fprintf(os.Stdout, "\tex: envpath add ~/.local/bin\n")
fmt.Fprintf(os.Stdout, "\tex: envpath remove ~/.local/bin\n")
}
func main() {
var action string
var entry string
if len(os.Args) < 2 {
usage()
os.Exit(1)
return
} else if len(os.Args) > 3 {
usage()
os.Exit(1)
return
}
action = os.Args[1]
if 2 == len(os.Args) {
entry = os.Args[2]
}
// https://superuser.com/a/69190/73857
// https://github.com/rust-lang-nursery/rustup.rs/issues/686#issuecomment-253982841
// exec source $HOME/.profile
shell := os.Getenv("SHELL")
switch shell {
case "":
if strings.HasSuffix(os.Getenv("COMSPEC"), "/cmd.exe") {
shell = "cmd"
}
case "fish":
// ignore
case "zsh":
// ignore
case "bash":
// ignore
default:
// warn and try anyway
fmt.Fprintf(
os.Stderr,
"%q isn't a recognized shell. Please open an issue at https://git.rootprojects.org/envpath/issues?q=%s",
shell,
shell,
)
}
switch action {
case "list":
if 2 == len(os.Args) {
usage()
os.Exit(1)
}
list()
case "add":
add(entry)
case "remove":
remove(entry)
}
}
func list() {
managedpaths, err := listPaths()
if nil != err {
fmt.Fprintf(os.Stderr, "%s", err)
os.Exit(1)
}
fmt.Println("pathman-managed PATH entries:\n")
for i := range managedpaths {
fmt.Println("\t" + managedpaths[i])
}
if 0 == len(managedpaths) {
fmt.Println("\t(none)")
}
fmt.Println("")
fmt.Println("other PATH entries:\n")
// All managed paths
pathsmap := map[string]bool{}
for i := range managedpaths {
// TODO normalize
pathsmap[managedpaths[i]] = true
}
// Paths in the environment which are not managed
var hasExtras bool
envpaths := Paths()
for i := range envpaths {
// TODO normalize
path := envpaths[i]
if !pathsmap[path] {
hasExtras = true
fmt.Println("\t" + path)
}
}
if !hasExtras {
fmt.Println("\t(none)")
}
fmt.Println("")
}
func add(entry string) {
// TODO noramlize away $HOME, %USERPROFILE%, etc
abspath, err := filepath.Abs(entry)
stat, err := os.Stat(entry)
if nil != err {
fmt.Fprintf(os.Stderr, "warning: couldn't access %q: %s\n", abspath, err)
} else if !stat.IsDir() {
fmt.Fprintf(os.Stderr, "warning: %q is not a directory", abspath)
}
modified, err := addPath(entry)
if nil != err {
fmt.Fprintf(os.Stderr, "failed to add %q to PATH: %s", entry, err)
os.Exit(1)
}
var msg string
if modified {
msg = "Saved PATH changes."
} else {
msg = "PATH not changed."
}
paths := Paths()
index := indexOfPath(Paths(), entry)
if -1 == index {
// TODO is os.PathListSeparator correct in MINGW / git bash?
// generally this has no effect, but just in case this is included in a library with children processes
paths = append([]string{entry}, paths...)
err = os.Setenv(`PATH`, strings.Join(paths, string(os.PathListSeparator)))
if nil != err {
// ignore and carry on, as this is optional
fmt.Fprintf(os.Stderr, "%s", err)
}
msg += " To set the PATH immediately, update the current session:\n\n\t" + Add(entry) + "\n"
}
fmt.Println(msg + "\n")
}
func remove(entry string) {
modified, err := removePath(entry)
if nil != err {
fmt.Fprintf(os.Stderr, "failed to add %q to PATH: %s", entry, err)
os.Exit(1)
}
var msg string
if modified {
msg = "Saved PATH changes."
} else {
msg = "PATH not changed."
}
paths := Paths()
index := indexOfPath(Paths(), entry)
if index >= 0 {
newpaths := []string{}
for i := range paths {
if i != index {
newpaths = append(newpaths, paths[i])
}
}
// TODO is os.PathListSeparator correct in MINGW / git bash?
// generally this has no effect, but just in case this is included in a library with children processes
err = os.Setenv(`PATH`, strings.Join(newpaths, string(os.PathListSeparator)))
if nil != err {
// ignore and carry on, as this is optional
fmt.Fprintf(os.Stderr, "%s", err)
}
msg += " To set the PATH immediately, update the current session:\n\n\t" + Remove(entry) + "\n"
}
fmt.Println(msg + "\n")
}
// Paths returns path entries in the current environment
func Paths() []string {
cur := os.Getenv("PATH")
if "" == cur {
// unlikely, but possible... so whatever
return nil
}
if isCmdExe() {
//return strings.Split(cur, string(os.PathListSeparator))
return strings.Split(cur, ";")
}
return strings.Split(cur, string(os.PathListSeparator))
}
// Add returns a string which can be used to add the given
// path entry to the current shell session
func Add(p string) string {
if isCmdExe() {
return fmt.Sprintf(`PATH %s;%PATH%`, p)
}
return fmt.Sprintf(`export PATH="%s:$PATH"`, p)
}
// Remove returns a string which can be used to remove the given
// path entry from the current shell session
func Remove(entries []string) string {
if isCmdExe() {
return fmt.Sprintf(`PATH %s`, strings.Join(entries, ";"))
}
return fmt.Sprintf(`export PATH="%s"`, strings.Join(entries, ":"))
}
func isCmdExe() {
return "" == os.Getenv("SHELL") && strings.Contains(strings.ToLower(os.Getenv("COMSPEC")), "/cmd.exe")
}

23
pathman_unixes.go

@ -0,0 +1,23 @@
// +build windows
package main
import (
"git.rootprojects.org/root/pathman/envpath"
)
func addPath(p string) (bool, error) {
return envpath.Add(p)
}
func removePath(p string) (bool, error) {
return envpath.Remove(p)
}
func listPaths() ([]string, error) {
return envpath.List()
}
func indexOfPath(cur []string, p string) int {
return envpath.IndexOf(cur, p)
}

23
pathman_windows.go

@ -0,0 +1,23 @@
// +build windows
package main
import (
"git.rootprojects.org/root/pathman/winpath"
)
func addPath(p string) (bool, error) {
return winpath.Add(p)
}
func removePath(p string) (bool, error) {
return winpath.Remove(p)
}
func listPaths() ([]string, error) {
return winpath.List()
}
func indexOfPath(cur []string, p string) int {
return winpath.IndexOf(cur, p)
}

63
winpath/README.md

@ -0,0 +1,63 @@
# winpath
An example of getting, setting, and broadcasting PATHs on Windows.
This requires the `unsafe` package to use a syscall with special message poitners to update `PATH` without a reboot.
It will also build without `unsafe`.
```bash
go build -tags unsafe -o winpath.exe
```
```bash
winpath show
%USERPROFILE%\AppData\Local\Microsoft\WindowsApps
C:\Users\me\AppData\Local\Programs\Microsoft VS Code\bin
%USERPROFILE%\go\bin
C:\Users\me\AppData\Roaming\npm
C:\Users\me\AppData\Local\Keybase\
```
```bash
winpath append C:\someplace\special
Run the following for changes to take affect immediately:
PATH %PATH%;C:\someplace\special
```
```bash
winpath prepend C:\someplace\special
Run the following for changes to take affect immediately:
PATH C:\someplace\special;%PATH%
```
```bash
winpath remove C:\someplace\special
```
# Special Considerations
Giving away the secret sauce right here:
* `HWND_BROADCAST`
* `WM_SETTINGCHANGE`
This is essentially the snippet you need to have the HKCU and HKLM Environment registry keys propagated without rebooting:
```go
HWND_BROADCAST := uintptr(0xffff)
WM_SETTINGCHANGE := uintptr(0x001A)
_, _, err := syscall.
NewLazyDLL("user32.dll").
NewProc("SendMessageW").
Call(HWND_BROADCAST, WM_SETTINGCHANGE, 0, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("ENVIRONMENT"))))
```
* `os.Getenv("COMSPEC")`
* `os.Getenv("SHELL")`
If you check `SHELL` and it isn't empty, then you're probably in MINGW or some such.
If that's empty but `COMSPEC` isn't, you can be reasonably sure that you're in cmd.exe or Powershell.

16
winpath/native_test.go

@ -0,0 +1,16 @@
// +build windows
package winpath
import "testing"
func TestShow(t *testing.T) {
paths, err := Paths()
if nil != err {
t.Error(err)
}
if len(paths) < 1 {
t.Error("should have paths")
}
}

94
winpath/winpath.go

@ -0,0 +1,94 @@
// Package winpath is useful for managing PATH as part of the Environment
// in the Windows HKey Local User registry. It returns an error for most
// operations on non-Windows systems.
package winpath
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// ErrWrongPlatform indicates that this was not built for Windows
var ErrWrongPlatform = fmt.Errorf("method not implemented on this platform")
// sendmsg uses a syscall to broadcast the registry change so that
// new shells will get the new PATH immediately, without a reboot
var sendmsg func()
// Paths returns all PATHs according to the Windows HKLU registry
// (or nil on non-windows platforms)
func Paths() ([]string, error) {
return paths()
}
// Add will rewrite the Windows registry HKLU Environment,
// prepending the given directory path to the user's PATH.
// It will return whether the PATH was modified and an
// error if it should have been modified, but wasn't.
func Add(p string) (bool, error) {
return add(p)
}
// Remove will rewrite the Windows registry HKLU Environment
// without the given directory path.
// It will return whether the PATH was modified and an
// error if it should have been modified, but wasn't.
func Remove(p string) (bool, error) {
return remove(p)
}
// NormalizePathEntry will return the given directory path relative
// from its absolute path to the %USERPROFILE% (home) directory.
func NormalizePathEntry(pathentry string) (string, string) {
home, err := os.UserHomeDir()
if nil != err {
fmt.Fprintf(os.Stderr, "Couldn't get HOME directory. That's an unrecoverable hard fail.")
panic(err)
}
sep := string(os.PathSeparator)
absentry, _ := filepath.Abs(pathentry)
home, _ = filepath.Abs(home)
var homeentry string
if strings.HasPrefix(strings.ToLower(absentry)+sep, strings.ToLower(home)+sep) {
// %USERPROFILE% is allowed, but only for user PATH
// https://superuser.com/a/442163/73857
homeentry = `%USERPROFILE%` + pathentry[len(home):]
}
if absentry == pathentry {
absentry = ""
}
if homeentry == pathentry {
homeentry = ""
}
return absentry, homeentry
}
// IndexOf searches the given path list for first occurence
// of the given path entry and returns the index, or -1
func IndexOf(paths []string, p string) int {
abspath, homepath := NormalizePathEntry(p)
index := -1
for i := range paths {
if strings.ToLower(p) == strings.ToLower(paths[i]) {
index = i
break
}
if strings.ToLower(abspath) == strings.ToLower(paths[i]) {
index = i
break
}
if strings.ToLower(homepath) == strings.ToLower(paths[i]) {
index = i
break
}
}
return index
}

15
winpath/winpath_nonwindows.go

@ -0,0 +1,15 @@
// +build !windows
package winpath
func paths() ([]string, error) {
return nil, ErrWrongPlatform
}
func add(string) (bool, error) {
return false, ErrWrongPlatform
}
func remove(string) (bool, error) {
return false, ErrWrongPlatform
}

23
winpath/winpath_test.go

@ -0,0 +1,23 @@
package winpath
import (
"fmt"
"os"
"testing"
)
func TestNormalize(t *testing.T) {
home, _ := os.UserHomeDir()
absexp := ""
homeexp := "%USERPROFILE%" + string(os.PathSeparator) + "foo"
abspath, homepath := NormalizePathEntry(home + string(os.PathSeparator) + "foo")
if absexp != abspath {
t.Error(fmt.Errorf("Expected %q, but got %q", absexp, abspath))
}
if homeexp != homepath {
t.Error(fmt.Errorf("Expected %q, but got %q", homeexp, homepath))
}
}

32
winpath/winpath_unsafe.go

@ -0,0 +1,32 @@
// +build windows,unsafe
package winpath
import (
"fmt"
"os"
"syscall"
"unsafe"
)
const (
HWND_BROADCAST = uintptr(0xffff)
WM_SETTINGCHANGE = uintptr(0x001A)
)
func init() {
// WM_SETTING_CHANGE
// https://gist.github.com/microo8/c1b9525efab9bb462adf9d123e855c52
sendmsg = func() {
//x, y, err := syscall.
_, _, err := syscall.
NewLazyDLL("user32.dll").
NewProc("SendMessageW").
Call(HWND_BROADCAST, WM_SETTINGCHANGE, 0, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr("ENVIRONMENT"))))
//fmt.Fprintf(os.Stderr, "%d, %d, %s\n", x, y, err)
if nil != err {
fmt.Fprintf(os.Stderr, "%s\n", err)
}
}
}

120
winpath/winpath_windows.go

@ -0,0 +1,120 @@
// +build windows
package winpath
// Needs to
// * use the registry editor directly to avoid possible PATH truncation
// ( https://stackoverflow.com/questions/9546324/adding-directory-to-path-environment-variable-in-windows )
// ( https://superuser.com/questions/387619/overcoming-the-1024-character-limit-with-setx )
// * explicitly send WM_SETTINGCHANGE
// ( https://github.com/golang/go/issues/18680#issuecomment-275582179 )
import (
"fmt"
"os"
"strings"
"golang.org/x/sys/windows/registry"
)
func add(p string) (bool, error) {
cur, err := paths()
if nil != err {
return false, err
}
index := IndexOf(cur, p)
// skip silently, successfully
if index >= 0 {
return false, nil
}
k, err := registry.OpenKey(registry.CURRENT_USER, `Environment`, registry.SET_VALUE)
if err != nil {
return false, err
}
defer k.Close()
cur = append([]string{p}, cur...)
err = write(cur)
if nil != err {
return false, err
}
return true, nil
}
func remove(p string) (bool, error) {
cur, err := paths()
if nil != err {
return false, err
}
index := findMatch(cur, p)
// skip silently, successfully
if index < 0 {
return false, nil
}
var newpaths []string
for i := range cur {
if i != index {
newpaths = append(newpaths, cur[i])
}
}
err = write(cur)
if nil != err {
return false, err
}
return true, nil
}
func write(cur []string) error {
// TODO --system to add to the system PATH rather than the user PATH
k, err := registry.OpenKey(registry.CURRENT_USER, `Environment`, registry.QUERY_VALUE)
if err != nil {
return err
}
defer k.Close()
err = k.SetStringValue(`Path`, strings.Join(cur, string(os.PathListSeparator)))
if nil != err {
return err
}
err = k.Close()
if nil != err {
return err
}
if nil != sendmsg {
sendmsg()
} else {
fmt.Fprintf(os.Stderr, "Warning: added PATH, but you must reboot for changes to take effect\n")
}
return nil
}
func paths() ([]string, error) {
// This is the canonical reference, which is actually quite nice to have.
// TBH, it's a mess to do this on *nix systems.
k, err := registry.OpenKey(registry.CURRENT_USER, `Environment`, registry.QUERY_VALUE)
if err != nil {
return nil, err
}
defer k.Close()
// This is case insensitive on Windows.
// PATH, Path, path will all work.
s, _, err := k.GetStringValue("Path")
if err != nil {
return nil, err
}
// ";" on Windows
return strings.Split(s, string(os.PathListSeparator)), nil
}
Loading…
Cancel
Save