package main
import (
"errors"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"strings"
"github.com/alecthomas/kong"
"github.com/mickael-menu/zk/internal/cli"
"github.com/mickael-menu/zk/internal/cli/cmd"
"github.com/mickael-menu/zk/internal/core"
executil "github.com/mickael-menu/zk/internal/util/exec"
)
var Version = "dev"
var Build = "dev"
var root struct {
Init cmd . Init ` cmd group:"zk" help:"Create a new notebook in the given directory." `
Index cmd . Index ` cmd group:"zk" help:"Index the notes to be searchable." `
New cmd . New ` cmd group:"notes" help:"Create a new note in the given notebook directory." `
List cmd . List ` cmd group:"notes" help:"List notes matching the given criteria." `
Graph cmd . Graph ` cmd group:"notes" help:"Produce a graph of the notes matching the given criteria." `
Edit cmd . Edit ` cmd group:"notes" help:"Edit notes matching the given criteria." `
Tag cmd . Tag ` cmd group:"notes" help:"Manage the note tags." `
NotebookDir string ` type:path placeholder:PATH help:"Turn off notebook auto-discovery and set manually the notebook where commands are run." `
WorkingDir string ` short:W type:path placeholder:PATH help:"Run as if zk was started in <PATH> instead of the current working directory." `
NoInput NoInput ` help:"Never prompt or ask for confirmation." `
// ForceInput is a debugging flag overriding the default value of interaction prompts.
ForceInput string ` hidden xor:"input" `
Debug bool ` default:"0" hidden help:"Print a debug stacktrace on SIGINT." `
DebugStyle bool ` default:"0" hidden help:"Force styling output as XML tags." `
ShowHelp ShowHelp ` cmd hidden default:"1" `
LSP cmd . LSP ` cmd hidden `
Version kong . VersionFlag ` hidden help:"Print zk version." `
}
// NoInput is a flag preventing any user prompt when enabled.
type NoInput bool
func ( f NoInput ) BeforeApply ( container * cli . Container ) error {
container . Terminal . NoInput = true
return nil
}
// ShowHelp is the default command run. It's equivalent to `zk --help`.
type ShowHelp struct { }
func ( cmd * ShowHelp ) Run ( container * cli . Container ) error {
parser , err := kong . New ( & root , options ( container ) ... )
if err != nil {
return err
}
ctx , err := parser . Parse ( [ ] string { "--help" } )
if err != nil {
return err
}
return ctx . Run ( container )
}
func main ( ) {
args := os . Args [ 1 : ]
// Create the dependency graph.
container , err := cli . NewContainer ( Version )
fatalIfError ( err )
// Open the notebook if there's any.
dirs , args , err := parseDirs ( args )
fatalIfError ( err )
searchDirs , err := notebookSearchDirs ( dirs )
fatalIfError ( err )
err = container . SetCurrentNotebook ( searchDirs )
fatalIfError ( err )
// Run the alias or command.
if isAlias , err := runAlias ( container , args ) ; isAlias {
fatalIfError ( err )
} else {
parser , err := kong . New ( & root , options ( container ) ... )
fatalIfError ( err )
ctx , err := parser . Parse ( args )
fatalIfError ( err )
if root . Debug {
setupDebugMode ( )
}
if root . DebugStyle {
container . Styler . Styler = core . TagStyler
}
container . Terminal . ForceInput = root . ForceInput
// Index the current notebook except if the user is running the `index`
// command, otherwise it would hide the stats.
if ctx . Command ( ) != "index" {
if notebook , err := container . CurrentNotebook ( ) ; err == nil {
index := cmd . Index { Quiet : true }
err = index . RunWithNotebook ( container , notebook )
ctx . FatalIfErrorf ( err )
}
}
err = ctx . Run ( container )
ctx . FatalIfErrorf ( err )
}
}
func options ( container * cli . Container ) [ ] kong . Option {
term := container . Terminal
return [ ] kong . Option {
kong . Bind ( container ) ,
kong . Name ( "zk" ) ,
kong . UsageOnError ( ) ,
kong . HelpOptions {
Compact : true ,
FlagsLast : true ,
WrapUpperBound : 100 ,
NoExpandSubcommands : true ,
} ,
kong . Vars {
"version" : "zk " + strings . TrimPrefix ( Version , "v" ) ,
} ,
kong . Groups ( map [ string ] string {
"cmd" : "Commands:" ,
"filter" : "Filtering" ,
"sort" : "Sorting" ,
"format" : "Formatting" ,
"notes" : term . MustStyle ( "NOTES" , core . StyleYellow , core . StyleBold ) + "\n" + term . MustStyle ( "Edit or browse your notes" , core . StyleBold ) ,
"zk" : term . MustStyle ( "NOTEBOOK" , core . StyleYellow , core . StyleBold ) + "\n" + term . MustStyle ( "A notebook is a directory containing a collection of notes" , core . StyleBold ) ,
} ) ,
}
}
func fatalIfError ( err error ) {
if err != nil {
fmt . Fprintf ( os . Stderr , "zk: error: %v\n" , err )
os . Exit ( 1 )
}
}
func setupDebugMode ( ) {
c := make ( chan os . Signal )
go func ( ) {
stacktrace := make ( [ ] byte , 8192 )
for _ = range c {
length := runtime . Stack ( stacktrace , true )
fmt . Fprintf ( os . Stderr , "%s\n" , string ( stacktrace [ : length ] ) )
os . Exit ( 1 )
}
} ( )
signal . Notify ( c , os . Interrupt )
}
// runAlias will execute a user alias if the command is one of them.
func runAlias ( container * cli . Container , args [ ] string ) ( bool , error ) {
if len ( args ) < 1 {
return false , nil
}
runningAlias := os . Getenv ( "ZK_RUNNING_ALIAS" )
for alias , cmdStr := range container . Config . Aliases {
if alias == runningAlias || alias != args [ 0 ] {
continue
}
// Prevent infinite loop if an alias calls itself.
os . Setenv ( "ZK_RUNNING_ALIAS" , alias )
// Move to the current notebook's root directory before running the alias.
if notebook , err := container . CurrentNotebook ( ) ; err == nil {
cmdStr = ` cd " ` + notebook . Path + ` " && ` + cmdStr
}
cmd := executil . CommandFromString ( cmdStr , args [ 1 : ] ... )
cmd . Stdin = os . Stdin
cmd . Stdout = os . Stdout
cmd . Stderr = os . Stderr
err := cmd . Run ( )
if err != nil {
if err , ok := err . ( * exec . ExitError ) ; ok {
os . Exit ( err . ExitCode ( ) )
return true , nil
} else {
return true , err
}
}
return true , nil
}
return false , nil
}
// notebookSearchDirs returns the places where zk will look for a notebook.
// The first successful candidate will be used as the working directory from
// which path arguments are relative from.
//
// By order of precedence:
// 1. --notebook-dir flag
// 2. current working directory
// 3. ZK_NOTEBOOK_DIR environment variable
func notebookSearchDirs ( dirs cli . Dirs ) ( [ ] cli . Dirs , error ) {
wd , err := os . Getwd ( )
if err != nil {
return nil , err
}
// 1. --notebook-dir flag
if dirs . NotebookDir != "" {
// If --notebook-dir is used, we want to only check there to report
// "notebook not found" errors.
if dirs . WorkingDir == "" {
dirs . WorkingDir = wd
}
return [ ] cli . Dirs { dirs } , nil
}
candidates := [ ] cli . Dirs { }
// 2. current working directory
wdDirs := dirs
if wdDirs . WorkingDir == "" {
wdDirs . WorkingDir = wd
}
wdDirs . NotebookDir = wdDirs . WorkingDir
candidates = append ( candidates , wdDirs )
// 3. ZK_NOTEBOOK_DIR environment variable
if notebookDir , ok := os . LookupEnv ( "ZK_NOTEBOOK_DIR" ) ; ok {
dirs := dirs
dirs . NotebookDir = notebookDir
if dirs . WorkingDir == "" {
dirs . WorkingDir = notebookDir
}
candidates = append ( candidates , dirs )
}
return candidates , nil
}
// parseDirs returns the paths specified with the --notebook-dir and
// --working-dir flags.
//
// We need to parse these flags before Kong, because we might need it to
// resolve zk command aliases before parsing the CLI.
func parseDirs ( args [ ] string ) ( cli . Dirs , [ ] string , error ) {
var d cli . Dirs
var err error
// Split str by first "=" if present and return the split pair, otherwise return nil
makeSplitPair := func ( str string ) ( pair [ ] string ) {
re := regexp . MustCompile ( ` = ` )
slice := re . FindStringIndex ( str )
if slice == nil {
return nil
}
return [ ] string { str [ : slice [ 0 ] ] , str [ slice [ 1 ] : ] }
}
// Peek ahead at next value and pair with current if it exists, otherwise return nil
makePeekPair := func ( args [ ] string , index int ) ( pair [ ] string ) {
if len ( args ) <= ( index + 1 ) {
return nil
}
return [ ] string { args [ index ] , args [ index + 1 ] }
}
matchesLongOrShort := func ( str string , long string , short string ) bool {
return str == long || ( short != "" && str == short )
}
findFlag := func ( long string , short string , args [ ] string ) ( string , [ ] string , error ) {
newArgs := [ ] string { }
for i , arg := range args {
// We can be given "--notebook-dir x" (two args) or "--notebook-dir=x" (one arg)
// so we must test against the current argument split into two, and
// the current argument + the next.
splitPair := makeSplitPair ( arg )
peekPair := makePeekPair ( args , i )
var option string
var value string
if splitPair != nil && matchesLongOrShort ( splitPair [ 0 ] , long , short ) {
option = splitPair [ 0 ]
value = splitPair [ 1 ]
// skip 1 ahead
newArgs = append ( newArgs , args [ i + 1 : ] ... )
} else if peekPair != nil && matchesLongOrShort ( peekPair [ 0 ] , long , short ) {
option = peekPair [ 0 ]
value = peekPair [ 1 ]
// skip 2 ahead (arg and value)
newArgs = append ( newArgs , args [ i + 2 : ] ... )
} else {
// we either had no split pair or peek pair, or they didn't match the
// needle, so just save the given arg and keep looking.
newArgs = append ( newArgs , arg )
}
if option != "" && value != "" {
path , err := filepath . Abs ( value )
return path , newArgs , err
} else if option != "" && value == "" {
return "" , newArgs , errors . New ( option + " requires a path argument" )
} else if len ( args ) == ( i + 1 ) && matchesLongOrShort ( arg , long , short ) {
return "" , newArgs , errors . New ( arg + " requires a path argument" )
}
}
return "" , newArgs , nil
}
d . NotebookDir , args , err = findFlag ( "--notebook-dir" , "" , args )
if err != nil {
return d , args , err
}
d . WorkingDir , args , err = findFlag ( "--working-dir" , "-W" , args )
if err != nil {
return d , args , err
}
return d , args , nil
}