diff --git a/cmd/dir.go b/cmd/dir.go index be5e841..81fc40b 100644 --- a/cmd/dir.go +++ b/cmd/dir.go @@ -7,23 +7,30 @@ import ( "sort" "time" + ignore "github.com/sabhiram/go-gitignore" "github.com/zemenawi/zutils/pkg/colors" "github.com/zemenawi/zutils/pkg/formatter" ) type FileItem struct { - Path string - Size int64 + Path string + Size int64 + Ignored bool + RelPath string } type DirStats struct { - TotalSize int64 - FileCount int - DirCount int - LargestFiles []FileItem - LargestDirs []FileItem - LastModified time.Time - FileExtensions map[string]int + TotalSize int64 + TotalSizeIgnoring int64 + FileCount int + DirCount int + IgnoredFileCount int + IgnoredSize int64 + LargestFiles []FileItem + LargestDirs []FileItem + LastModified time.Time + FileExtensions map[string]int + HasGitignore bool } func DirInfoCommand(args []string) error { @@ -33,51 +40,67 @@ func DirInfoCommand(args []string) error { dirPath := args[0] - stats, err := analyzeDirectory(dirPath) + showIgnored := false + if len(args) > 1 && args[1] == "--all" { + showIgnored = true + } + + stats, err := analyzeDirectory(dirPath, showIgnored) if err != nil { return fmt.Errorf("error analyzing directory: %w", err) } size := formatter.FormatSize(stats.TotalSize) + ignoringSize := formatter.FormatSize(stats.TotalSizeIgnoring) modTime := stats.LastModified.Format(time.RFC1123) PrintBoxHeader("DIRECTORY INFORMATION", colors.Purple) fmt.Printf("%s%sšŸ“ Path:%s %s\n", colors.Blue, colors.Bold, colors.Reset, dirPath) fmt.Printf("%s%sšŸ“ Total Size:%s %s\n", colors.Blue, colors.Bold, colors.Reset, size) + if stats.HasGitignore { + fmt.Printf("%s%sšŸ“ Size (excl. ignored):%s %s\n", colors.Blue, colors.Bold, colors.Reset, ignoringSize) + fmt.Printf("%s%s🚫 Ignored:%s %d files (%s)\n", colors.Gray, colors.Bold, colors.Reset, stats.IgnoredFileCount, formatter.FormatSize(stats.IgnoredSize)) + } fmt.Printf("%s%sšŸ“Š Files:%s %d\n", colors.Blue, colors.Bold, colors.Reset, stats.FileCount) fmt.Printf("%s%sšŸ“‚ Directories:%s %d\n", colors.Blue, colors.Bold, colors.Reset, stats.DirCount) fmt.Println() - // Print largest files table if len(stats.LargestFiles) > 0 { PrintSectionHeader("LARGEST FILES") - headers := []string{"#", "File Name", "Size"} + if stats.HasGitignore { + headers = []string{"#", "File Name", "Size", "Status"} + } data := make([][]string, 0, len(stats.LargestFiles)) for i, file := range stats.LargestFiles { - name := filepath.Base(file.Path) + name := file.RelPath row := []string{ fmt.Sprintf("%d", i+1), name, formatter.FormatSize(file.Size), } + if stats.HasGitignore { + if file.Ignored { + row = append(row, colors.Gray+"[ignored]"+colors.Reset) + } else { + row = append(row, "") + } + } data = append(data, row) } printSimpleTable(headers, data) } - // Print largest directories table if len(stats.LargestDirs) > 0 { PrintSectionHeader("LARGEST DIRS") - headers := []string{"#", "Directory Name", "Size"} data := make([][]string, 0, len(stats.LargestDirs)) for i, dir := range stats.LargestDirs { - name := filepath.Base(dir.Path) + name := dir.RelPath row := []string{ fmt.Sprintf("%d", i+1), name, @@ -89,10 +112,8 @@ func DirInfoCommand(args []string) error { printSimpleTable(headers, data) } - // Print file type distribution table if len(stats.FileExtensions) > 0 { PrintSectionHeader("FILE TYPE DISTRIBUTION") - type extCount struct { ext string count int @@ -133,6 +154,9 @@ func DirInfoCommand(args []string) error { PrintSectionHeader("DIRECTORY DETAILS") fmt.Printf("%s%sšŸ“… Last Modified:%s %s\n", colors.Yellow, colors.Bold, colors.Reset, modTime) + if stats.HasGitignore { + fmt.Printf("%s%sšŸ“œ Gitignore:%s %s\n", colors.Green, colors.Bold, colors.Reset, "Detected") + } fmt.Println() return nil @@ -143,39 +167,67 @@ func printSimpleTable(headers []string, data [][]string) { fmt.Println() } -func analyzeDirectory(path string) (*DirStats, error) { +func analyzeDirectory(path string, showIgnored bool) (*DirStats, error) { stats := &DirStats{ LargestFiles: make([]FileItem, 0), LargestDirs: make([]FileItem, 0), FileExtensions: make(map[string]int), } - err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { + ignoreMatcher, err := ignore.CompileIgnoreFile(filepath.Join(path, ".gitignore")) + if err == nil { + stats.HasGitignore = true + _ = ignoreMatcher + } + + err = filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { if err != nil { return err } + relPath, _ := filepath.Rel(path, filePath) + if filePath == path { return nil } + ignored := false + if stats.HasGitignore { + ignored = ignoreMatcher.MatchesPath(relPath) + } + if info.IsDir() { - stats.DirCount++ - stats.LargestDirs = appendSorted(stats.LargestDirs, FileItem{ - Path: filePath, - Size: info.Size(), - }, 5) + if !ignored || showIgnored { + stats.DirCount++ + stats.LargestDirs = appendSorted(stats.LargestDirs, FileItem{ + Path: filePath, + Size: info.Size(), + Ignored: ignored, + RelPath: relPath, + }, 5) + } } else { stats.FileCount++ stats.TotalSize += info.Size() + if ignored { + stats.IgnoredFileCount++ + stats.IgnoredSize += info.Size() + } else { + stats.TotalSizeIgnoring += info.Size() + } + ext := filepath.Ext(filePath) stats.FileExtensions[ext]++ - stats.LargestFiles = appendSorted(stats.LargestFiles, FileItem{ - Path: filePath, - Size: info.Size(), - }, 5) + if !ignored || showIgnored { + stats.LargestFiles = appendSorted(stats.LargestFiles, FileItem{ + Path: filePath, + Size: info.Size(), + Ignored: ignored, + RelPath: relPath, + }, 5) + } if info.ModTime().After(stats.LastModified) { stats.LastModified = info.ModTime() @@ -200,4 +252,4 @@ func appendSorted(items []FileItem, newItem FileItem, maxSize int) []FileItem { } return items -} +} \ No newline at end of file diff --git a/cmd/find.go b/cmd/find.go new file mode 100644 index 0000000..efa61cd --- /dev/null +++ b/cmd/find.go @@ -0,0 +1,192 @@ +package cmd + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/zemenawi/zutils/pkg/colors" + "github.com/zemenawi/zutils/pkg/formatter" +) + +type FindOptions struct { + Pattern string + SizeMin int64 + SizeMax int64 + ModifiedDays int + FileType string + MaxDepth int + ShowHidden bool +} + +type FindResult struct { + Path string + Size int64 + Modified time.Time + IsDir bool + Permissions string +} + +func FindCommand(args []string) error { + fs := flag.NewFlagSet("find", flag.ContinueOnError) + options := FindOptions{} + + fs.StringVar(&options.Pattern, "name", "*", "File name pattern (glob)") + fs.StringVar(&options.FileType, "type", "", "File type: f (file), d (dir)") + sizeMin := fs.Int64("size-min", 0, "Minimum size in bytes") + sizeMax := fs.Int64("size-max", 0, "Maximum size in bytes") + modifiedDays := fs.Int("mtime", 0, "Modified within N days") + maxDepth := fs.Int("depth", -1, "Maximum directory depth") + showHidden := fs.Bool("hidden", false, "Show hidden files") + + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return nil + } + return fmt.Errorf("invalid arguments: %v\n\nUsage: z find [path] [options]\n\nOptions:\n%s\n\nExamples:\n z find . -name '*.go' Find all Go files\n z find . -mtime 7 Files modified in last 7 days\n z find . -size +1m Files larger than 1MB\n z find . -type d Directories only", err, getFlagDefaults(fs)) + } + + options.SizeMin = *sizeMin + options.SizeMax = *sizeMax + options.ModifiedDays = *modifiedDays + options.MaxDepth = *maxDepth + options.ShowHidden = *showHidden + + path := "." + if fs.NArg() > 0 { + path = fs.Arg(0) + } + + if path == "" { + return fmt.Errorf("missing search path\n\nUsage: z find [path] [options]") + } + + results, err := findFiles(path, options) + if err != nil { + return fmt.Errorf("error finding files: %v", err) + } + + if len(results) == 0 { + fmt.Printf("%sNo files found matching criteria%s\n", colors.Gray, colors.Reset) + return nil + } + + sort.Slice(results, func(i, j int) bool { + return results[i].Modified.After(results[j].Modified) + }) + + PrintBoxHeader(fmt.Sprintf("FIND RESULTS (%d files)", len(results)), colors.Green) + + headers := []string{"Path", "Size", "Modified"} + data := make([][]string, 0, len(results)) + + for _, result := range results { + relPath := result.Path + if strings.Contains(relPath, "/") { + parts := strings.Split(relPath, "/") + relPath = strings.Join(parts[len(parts)-2:], "/") + if len(parts) > 2 { + relPath = "..." + string(filepath.Separator) + strings.Join(parts[len(parts)-2:], "/") + } + } + + modStr := result.Modified.Format("Jan 02 15:04") + if time.Since(result.Modified) > 30*24*time.Hour { + modStr = result.Modified.Format("Jan 02 2006") + } + + row := []string{ + relPath, + formatter.FormatSize(result.Size), + modStr, + } + data = append(data, row) + } + + formatter.PrintTable(headers, data, colors.Cyan) + fmt.Println() + + return nil +} + +func getFlagDefaults(fs *flag.FlagSet) string { + var result []string + fs.VisitAll(func(f *flag.Flag) { + result = append(result, fmt.Sprintf(" -%s %s (default: %v)", f.Name, f.Usage, f.DefValue)) + }) + return strings.Join(result, "\n") +} + +func findFiles(path string, opts FindOptions) ([]FindResult, error) { + var results []FindResult + + err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + if filePath == path { + return nil + } + + if !opts.ShowHidden && filepath.Base(filePath)[0] == '.' { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + + if opts.MaxDepth > 0 { + relPath, _ := filepath.Rel(path, filePath) + if strings.Count(relPath, string(filepath.Separator)) > opts.MaxDepth { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + } + + if opts.FileType == "f" && info.IsDir() { + return nil + } + if opts.FileType == "d" && !info.IsDir() { + return nil + } + + baseName := filepath.Base(filePath) + matched, err := filepath.Match(opts.Pattern, baseName) + if err != nil || !matched { + return nil + } + + if opts.SizeMin > 0 && info.Size() < opts.SizeMin { + return nil + } + if opts.SizeMax > 0 && info.Size() > opts.SizeMax { + return nil + } + + if opts.ModifiedDays > 0 { + cutoff := time.Now().AddDate(0, 0, -opts.ModifiedDays) + if info.ModTime().Before(cutoff) { + return nil + } + } + + results = append(results, FindResult{ + Path: filePath, + Size: info.Size(), + Modified: info.ModTime(), + IsDir: info.IsDir(), + Permissions: info.Mode().String(), + }) + + return nil + }) + + return results, err +} \ No newline at end of file diff --git a/cmd/help.go b/cmd/help.go new file mode 100644 index 0000000..2899f26 --- /dev/null +++ b/cmd/help.go @@ -0,0 +1,193 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/zemenawi/zutils/pkg/colors" +) + +type CommandHelp struct { + Name string + Help string + Examples []string +} + +var readHelp = CommandHelp{ + Name: "read", + Help: "Read and display file with smart detection.\n" + + "Automatically detects file type:\n" + + " - Markdown (.md) - Renders with colored headings, lists, code blocks\n" + + " - Code files - Syntax highlighting with line numbers\n" + + " - Other files - Plain text display", + Examples: []string{ + "z read README.md # Read markdown file", + "z read main.go # Read code with syntax highlighting", + "z read package.json # Read JSON file", + }, +} + +var sysHelp = CommandHelp{ + Name: "sys", + Help: "Show system overview including CPU, memory, disk usage, and top processes.\n" + + "Useful for quick system diagnostics.", + Examples: []string{ + "z sys # Show full system overview", + }, +} + +var findHelp = CommandHelp{ + Name: "find", + Help: "Find files matching criteria.\n" + + "Supports glob patterns, size filters, modification time, and depth limits.", + Examples: []string{ + "z find . -name '*.go' # Find all Go files", + "z find . -mtime 7 # Files modified in last 7 days", + "z find . -size +1m # Files larger than 1MB", + "z find . -type d # Directories only", + }, +} + +var searchHelp = CommandHelp{ + Name: "search", + Help: "Search for a pattern in files.\n" + + "A user-friendly alternative to grep with colored output.", + Examples: []string{ + "z search func cmd/ # Search for 'func' in cmd directory", + "z search -i 'error' . # Case-insensitive search", + "z search -w 'import' *.go # Whole word match", + "z search -C 2 'TODO' . # Show 2 lines of context", + }, +} + +var usageHelp = CommandHelp{ + Name: "usage", + Help: "Show resource usage (CPU, memory) for processes matching a name.\n" + + "Useful for monitoring specific applications or services.", + Examples: []string{ + "z usage cron # Show cron process usage", + "z usage mongod # Show MongoDB usage", + "z usage chrome # Show all Chrome processes", + "z usage postgres # Show PostgreSQL usage", + }, +} + +func PrintErrorAndHelp(name string, err error) { + help := getHelpForCommand(name) + + fmt.Printf("\n %sāœ– Error: %s%s\n\n", colors.Red, err, colors.Reset) + + if help != nil { + fmt.Printf(" %sUsage:%s z %s %s\n\n", colors.Bold, colors.Reset, help.Name, getUsageHint(help.Name)) + + if help.Help != "" { + fmt.Printf(" %sDescription:%s\n", colors.Bold, colors.Reset) + lines := splitIntoLines(help.Help, 60) + for _, line := range lines { + fmt.Printf(" %s%s%s\n", colors.Reset, line, colors.Reset) + } + fmt.Printf("\n") + } + + if len(help.Examples) > 0 { + fmt.Printf(" %sExamples:%s\n", colors.Bold, colors.Reset) + for _, ex := range help.Examples { + fmt.Printf(" %s%s%s\n", colors.Green, ex, colors.Reset) + } + fmt.Printf("\n") + } + } +} + +func getHelpForCommand(name string) *CommandHelp { + switch name { + case "read": + return &readHelp + case "sys": + return &sysHelp + case "find": + return &findHelp + case "search": + return &searchHelp + case "usage": + return &usageHelp + } + return nil +} + +func getUsageHint(name string) string { + switch name { + case "read": + return "" + case "sys": + return "" + case "find": + return "[path] [options]" + case "search": + return " [path]" + case "usage": + return "" + case "info": + return "" + } + return "" +} + +func splitIntoLines(text string, maxLen int) []string { + var lines []string + paragraphs := splitIntoParagraphs(text) + + for _, para := range paragraphs { + if para == "" { + lines = append(lines, "") + continue + } + + words := strings.Fields(para) + currentLine := "" + + for _, word := range words { + if currentLine == "" { + currentLine = word + } else if len(currentLine)+len(word)+1 <= maxLen { + currentLine += " " + word + } else { + lines = append(lines, currentLine) + currentLine = word + } + } + + if currentLine != "" { + lines = append(lines, currentLine) + } + } + + return lines +} + +func splitIntoParagraphs(text string) []string { + var paragraphs []string + lines := strings.Split(text, "\n") + + current := "" + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + if current != "" { + paragraphs = append(paragraphs, current) + current = "" + } + } else { + if current != "" { + current += " " + } + current += trimmed + } + } + + if current != "" { + paragraphs = append(paragraphs, current) + } + + return paragraphs +} diff --git a/cmd/read.go b/cmd/read.go index 228ed53..5c4718f 100644 --- a/cmd/read.go +++ b/cmd/read.go @@ -1,77 +1,48 @@ package cmd import ( - "bytes" "fmt" "os" - "path/filepath" - "strings" - "github.com/alecthomas/chroma/v2/formatters" - "github.com/alecthomas/chroma/v2/lexers" - "github.com/alecthomas/chroma/v2/styles" - "github.com/zemenawi/zutils/pkg/colors" "github.com/zemenawi/zutils/pkg/formatter" ) +var ReadHelp = CommandHelp{ + Name: "read", + Help: "Read and display file with smart detection.\nAutomatically detects file type:\n - Markdown (.md) - Renders with colored headings, lists, code blocks\n - Code files - Syntax highlighting with line numbers\n - Other files - Plain text display", + Examples: []string{ + "z read README.md # Read markdown file", + "z read main.go # Read code with syntax highlighting", + "z read package.json # Read JSON file", + }, +} + func ReadCommand(args []string) error { if len(args) < 1 { - return fmt.Errorf("please specify a file path") + return fmt.Errorf("missing file path\n\nUsage: z read ") } filePath := args[0] file, err := os.Open(filePath) if err != nil { - return fmt.Errorf("error opening file: %w", err) + return fmt.Errorf("cannot open file '%s': %s", filePath, err) } defer file.Close() fileInfo, err := file.Stat() if err != nil { - return fmt.Errorf("error getting file info: %w", err) + return fmt.Errorf("cannot read file info '%s': %s", filePath, err) } if fileInfo.IsDir() { - return fmt.Errorf("'%s' is a directory, not a readable file", filePath) + return fmt.Errorf("'%s' is a directory\n\nUse: z info dir %s", filePath, filePath) } - content := formatter.ReadFileContent(file) - - lexer := lexers.Match(filepath.Base(filePath)) - if lexer == nil { - lexer = lexers.Fallback + handler := formatter.ReadHandlers.GetHandler(filePath) + if handler != nil { + return handler(args) } - iterator, err := lexer.Tokenise(nil, content) - if err != nil { - return fmt.Errorf("error tokenizing: %w", err) - } - - formatter_ := formatters.TTY256 - style := styles.Get("monokai") - if style == nil { - style = styles.Fallback - } - - fmt.Printf("%s%s╔══════════════════════════════════════╗%s\n", colors.Cyan, colors.Bold, colors.Reset) - fmt.Printf("%s%sā•‘%s %-38s %s%sā•‘%s\n", colors.Cyan, colors.Bold, colors.Reset, filePath, colors.Cyan, colors.Bold, colors.Reset) - fmt.Printf("%s%sā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•%s\n", colors.Cyan, colors.Bold, colors.Reset) - fmt.Println() - - var buf bytes.Buffer - err = formatter_.Format(&buf, style, iterator) - if err != nil { - return fmt.Errorf("error formatting: %w", err) - } - - lines := strings.Split(buf.String(), "\n") - for i, line := range lines { - if line != "" { - fmt.Printf("%s%4d: %s%s\n", colors.Gray, i+1, colors.Reset, line) - } - } - - return nil -} - + return formatter.ReadFileWithHighlight(file, filePath) +} \ No newline at end of file diff --git a/cmd/markdown.go b/cmd/read_md.go similarity index 86% rename from cmd/markdown.go rename to cmd/read_md.go index 5255487..914b259 100644 --- a/cmd/markdown.go +++ b/cmd/read_md.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "os" - "path/filepath" "strings" "github.com/alecthomas/chroma/v2/formatters" @@ -13,7 +12,12 @@ import ( "github.com/zemenawi/zutils/pkg/formatter" ) -func MarkdownCommand(args []string) error { +func init() { + formatter.ReadHandlers.Register(".md", MarkdownHandler) + formatter.ReadHandlers.Register(".markdown", MarkdownHandler) +} + +func MarkdownHandler(args []string) error { if len(args) < 1 { return fmt.Errorf("please specify a markdown file path") } @@ -35,11 +39,6 @@ func MarkdownCommand(args []string) error { return fmt.Errorf("'%s' is a directory, not a readable file", filePath) } - ext := filepath.Ext(filePath) - if ext != ".md" && ext != ".markdown" { - return fmt.Errorf("'%s' is not a markdown file", filePath) - } - content := formatter.ReadFileContent(file) fmt.Printf("%s%s╔══════════════════════════════════════╗%s\n", colors.Cyan, colors.Bold, colors.Reset) @@ -52,7 +51,7 @@ func MarkdownCommand(args []string) error { codeBuffer := &strings.Builder{} codeLang := "" - for i, line := range lines { + for _, line := range lines { if strings.HasPrefix(line, "```") { if !inCodeBlock { inCodeBlock = true @@ -94,7 +93,6 @@ func MarkdownCommand(args []string) error { } else { fmt.Println() } - _ = i } return nil @@ -105,20 +103,22 @@ func renderCodeBlock(code, lang string) { return } + fmt.Printf("%s%s╔═══ %s ═══╗%s\n", colors.Gray, colors.Dim, lang, colors.Reset) + lexer := lexers.Match(lang) if lexer == nil { lexer = lexers.Fallback } iterator, _ := lexer.Tokenise(nil, code) - formatter := formatters.TTY256 + formatter_ := formatters.TTY256 style := styles.Get("monokai") if style == nil { style = styles.Fallback } var buf strings.Builder - formatter.Format(&buf, style, iterator) + formatter_.Format(&buf, style, iterator) output := buf.String() for _, line := range strings.Split(output, "\n") { @@ -126,5 +126,6 @@ func renderCodeBlock(code, lang string) { fmt.Printf("%s%s│ %s%s\n", colors.Gray, colors.Dim, colors.Reset, line) } } + fmt.Printf("%s%sā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•%s\n", colors.Gray, colors.Dim, colors.Reset) fmt.Println() } \ No newline at end of file diff --git a/cmd/search.go b/cmd/search.go new file mode 100644 index 0000000..66eb07f --- /dev/null +++ b/cmd/search.go @@ -0,0 +1,180 @@ +package cmd + +import ( + "bufio" + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/zemenawi/zutils/pkg/colors" +) + +type SearchOptions struct { + Pattern string + Path string + CaseInsensitive bool + WholeWord bool + LineNumbers bool + ContextLines int + FilePattern string +} + +type SearchResult struct { + File string + Line int + Content string +} + +func SearchCommand(args []string) error { + fs := flag.NewFlagSet("search", flag.ContinueOnError) + options := SearchOptions{} + + fs.BoolVar(&options.CaseInsensitive, "i", false, "Case insensitive search") + fs.BoolVar(&options.WholeWord, "w", false, "Match whole word") + fs.BoolVar(&options.LineNumbers, "n", true, "Show line numbers") + fs.IntVar(&options.ContextLines, "C", 0, "Show N lines of context") + fs.StringVar(&options.FilePattern, "f", "*", "Search only in files matching pattern") + + if err := fs.Parse(args); err != nil { + if err == flag.ErrHelp { + return nil + } + return fmt.Errorf("invalid arguments\n\nUsage: z search [options] pattern [path]\n\nExamples:\n z search func cmd/ Search for 'func' in cmd directory\n z search -i 'error' . Case-insensitive search\n z search -w 'import' *.go Whole word match in Go files") + } + + if fs.NArg() < 1 { + return fmt.Errorf("missing search pattern\n\nUsage: z search [options] pattern [path]\n\nExamples:\n z search func cmd/ Search for 'func' in cmd directory\n z search -i 'error' . Case-insensitive search\n z search -w 'import' *.go Whole word match in Go files") + } + + options.Pattern = fs.Arg(0) + if fs.NArg() > 1 { + options.Path = fs.Arg(1) + } else { + options.Path = "." + } + + results, err := searchFiles(options) + if err != nil { + return fmt.Errorf("search error: %v", err) + } + + if len(results) == 0 { + fmt.Printf("%sNo matches found for '%s'%s\n", colors.Gray, options.Pattern, colors.Reset) + return nil + } + + displaySearchResults(results, options) + + return nil +} + +func searchFiles(opts SearchOptions) ([]SearchResult, error) { + var results []SearchResult + + pattern := opts.Pattern + if opts.CaseInsensitive { + pattern = strings.ToLower(pattern) + } + + var regex *regexp.Regexp + if opts.WholeWord { + regex = regexp.MustCompile(`\b` + regexp.QuoteMeta(pattern) + `\b`) + } else { + regex = regexp.MustCompile(regexp.QuoteMeta(pattern)) + } + + err := filepath.Walk(opts.Path, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + if info.IsDir() { + return nil + } + + baseName := filepath.Base(path) + matched, err := filepath.Match(opts.FilePattern, baseName) + if err != nil || !matched { + return nil + } + + fileResults := searchFile(path, regex, opts) + results = append(results, fileResults...) + + return nil + }) + + return results, err +} + +func searchFile(path string, regex *regexp.Regexp, opts SearchOptions) []SearchResult { + var results []SearchResult + + file, err := os.Open(path) + if err != nil { + return results + } + defer file.Close() + + scanner := bufio.NewScanner(file) + lineNum := 0 + + for scanner.Scan() { + lineNum++ + line := scanner.Text() + check := line + + if opts.CaseInsensitive { + check = strings.ToLower(check) + } + + if regex.MatchString(check) { + results = append(results, SearchResult{ + File: path, + Line: lineNum, + Content: line, + }) + } + } + + return results +} + +func displaySearchResults(results []SearchResult, opts SearchOptions) { + files := make(map[string][]SearchResult) + for _, r := range results { + files[r.File] = append(files[r.File], r) + } + + PrintBoxHeader(fmt.Sprintf("SEARCH RESULTS (%d matches in %d files)", len(results), len(files)), colors.Cyan) + + for file, fileResults := range files { + relPath := file + if strings.Contains(relPath, "/") { + parts := strings.Split(relPath, "/") + if len(parts) > 3 { + relPath = ".../" + strings.Join(parts[len(parts)-2:], "/") + } + } + + fmt.Printf("\n%s%s%s:%s\n", colors.Bold, colors.Yellow, relPath, colors.Reset) + + for _, r := range fileResults { + content := r.Content + if len(content) > 100 { + content = content[:97] + "..." + } + + if opts.LineNumbers { + fmt.Printf("%s%4d:%s %s\n", colors.Gray, r.Line, colors.Reset, content) + } else { + fmt.Printf(" %s\n", content) + } + } + } + + fmt.Println() +} diff --git a/cmd/sys.go b/cmd/sys.go new file mode 100644 index 0000000..8f28157 --- /dev/null +++ b/cmd/sys.go @@ -0,0 +1,370 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "syscall" + "time" + + "github.com/zemenawi/zutils/pkg/colors" + "github.com/zemenawi/zutils/pkg/formatter" +) + +type SystemStats struct { + Hostname string + OS string + Platform string + CPUModel string + CPUCount int + Uptime string + LoadAvg string + MemTotal int64 + MemUsed int64 + MemFree int64 + DiskTotal int64 + DiskUsed int64 + DiskFree int64 + TopProcs []ProcInfo +} + +type ProcInfo struct { + PID int + Name string + CPU float64 + Mem float64 + Command string +} + +func SystemCommand(args []string) error { + stats, err := getSystemStats() + if err != nil { + return fmt.Errorf("error getting system stats: %v", err) + } + + PrintBoxHeader("SYSTEM OVERVIEW", colors.Cyan) + + fmt.Printf("%s%sšŸ  Hostname:%s %s\n", colors.Blue, colors.Bold, colors.Reset, stats.Hostname) + fmt.Printf("%s%sšŸ’» OS:%s %s\n", colors.Blue, colors.Bold, colors.Reset, stats.OS) + fmt.Printf("%s%sšŸ”§ Platform:%s %s\n", colors.Blue, colors.Bold, colors.Reset, stats.Platform) + fmt.Println() + + PrintSectionHeader("CPU") + fmt.Printf("%s%sšŸ–„ļø Model:%s %s\n", colors.Green, colors.Bold, colors.Reset, stats.CPUModel) + fmt.Printf("%s%sšŸ”¢ Cores:%s %d\n", colors.Green, colors.Bold, colors.Reset, stats.CPUCount) + fmt.Printf("%s%sšŸ“Š Load Avg:%s %s\n", colors.Green, colors.Bold, colors.Reset, stats.LoadAvg) + fmt.Printf("%s%sā±ļø Uptime:%s %s\n", colors.Green, colors.Bold, colors.Reset, stats.Uptime) + fmt.Println() + + PrintSectionHeader("MEMORY") + memTotal := formatter.FormatSize(stats.MemTotal) + memUsed := formatter.FormatSize(stats.MemUsed) + memFree := formatter.FormatSize(stats.MemFree) + memPct := float64(stats.MemUsed) / float64(stats.MemTotal) * 100 + + fmt.Printf("%s%sšŸ’¾ Total:%s %s\n", colors.Purple, colors.Bold, colors.Reset, memTotal) + fmt.Printf("%s%sšŸ“ˆ Used:%s %s (%.1f%%)\n", colors.Purple, colors.Bold, colors.Reset, memUsed, memPct) + fmt.Printf("%s%sšŸ“‰ Free:%s %s\n", colors.Purple, colors.Bold, colors.Reset, memFree) + printMemoryBar(stats.MemUsed, stats.MemTotal) + fmt.Println() + + PrintSectionHeader("DISK") + diskTotal := formatter.FormatSize(stats.DiskTotal) + diskUsed := formatter.FormatSize(stats.DiskUsed) + diskFree := formatter.FormatSize(stats.DiskFree) + diskPct := float64(stats.DiskUsed) / float64(stats.DiskTotal) * 100 + + fmt.Printf("%s%sšŸ’½ Total:%s %s\n", colors.Yellow, colors.Bold, colors.Reset, diskTotal) + fmt.Printf("%s%sšŸ“ˆ Used:%s %s (%.1f%%)\n", colors.Yellow, colors.Bold, colors.Reset, diskUsed, diskPct) + fmt.Printf("%s%sšŸ“‰ Free:%s %s\n", colors.Yellow, colors.Bold, colors.Reset, diskFree) + printDiskBar(stats.DiskUsed, stats.DiskTotal) + fmt.Println() + + if len(stats.TopProcs) > 0 { + PrintSectionHeader("TOP PROCESSES") + headers := []string{"PID", "Name", "CPU%", "MEM%"} + data := make([][]string, 0, len(stats.TopProcs)) + + for _, proc := range stats.TopProcs { + row := []string{ + fmt.Sprintf("%d", proc.PID), + truncateString(proc.Name, 20), + fmt.Sprintf("%.1f", proc.CPU), + fmt.Sprintf("%.1f", proc.Mem), + } + data = append(data, row) + } + + formatter.PrintTable(headers, data, colors.Cyan) + fmt.Println() + } + + return nil +} + +func printMemoryBar(used, total int64) { + width := 30 + if total == 0 { + return + } + fillLen := int(float64(used) / float64(total) * float64(width)) + + bar := "[" + for i := 0; i < width; i++ { + if i < fillLen { + if i < width/3 { + bar += colors.Green + "ā–ˆ" + colors.Reset + } else if i < 2*width/3 { + bar += colors.Yellow + "ā–ˆ" + colors.Reset + } else { + bar += colors.Red + "ā–ˆ" + colors.Reset + } + } else { + bar += colors.Gray + "ā–‘" + colors.Reset + } + } + bar += "]" + + fmt.Printf("%s%sMemory Bar:%s %s\n", colors.Gray, colors.Bold, colors.Reset, bar) +} + +func printDiskBar(used, total int64) { + width := 30 + if total == 0 { + return + } + fillLen := int(float64(used) / float64(total) * float64(width)) + + bar := "[" + for i := 0; i < width; i++ { + if i < fillLen { + if i < width/3 { + bar += colors.Green + "ā–ˆ" + colors.Reset + } else if i < 2*width/3 { + bar += colors.Yellow + "ā–ˆ" + colors.Reset + } else { + bar += colors.Red + "ā–ˆ" + colors.Reset + } + } else { + bar += colors.Gray + "ā–‘" + colors.Reset + } + } + bar += "]" + + fmt.Printf("%s%sDisk Bar:%s %s\n", colors.Gray, colors.Bold, colors.Reset, bar) +} + +func getSystemStats() (*SystemStats, error) { + stats := &SystemStats{} + + stats.Hostname, _ = os.Hostname() + stats.OS = runtime.GOOS + stats.Platform = runtime.GOARCH + + stats.CPUModel = getCPUModel() + stats.CPUCount = runtime.NumCPU() + + stats.Uptime = getUptime() + stats.LoadAvg = getLoadAvg() + + getMemoryStats(stats) + getDiskStats(stats) + + stats.TopProcs = getTopProcs(5) + + return stats, nil +} + +func getCPUModel() string { + if runtime.GOOS == "linux" { + if data, err := os.ReadFile("/proc/cpuinfo"); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "model name") || strings.HasPrefix(line, "Processor") || strings.HasPrefix(line, "cpu model") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + return strings.TrimSpace(parts[1]) + } + } + } + } + } + return "Unknown" +} + +func getUptime() string { + if runtime.GOOS == "linux" { + if data, err := os.ReadFile("/proc/uptime"); err == nil { + parts := strings.Split(string(data), " ") + if len(parts) >= 1 { + if secs, err := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64); err == nil { + duration := time.Duration(int64(secs)) * time.Second + return formatDuration(duration) + } + } + } + } + return "Unknown" +} + +func formatDuration(d time.Duration) string { + days := int(d.Hours() / 24) + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) + } + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, minutes) + } + return fmt.Sprintf("%dm", minutes) +} + +func getLoadAvg() string { + if runtime.GOOS == "linux" { + if data, err := os.ReadFile("/proc/loadavg"); err == nil { + parts := strings.Split(strings.TrimSpace(string(data)), " ") + if len(parts) >= 3 { + return fmt.Sprintf("%.2f %.2f %.2f", + parseFloatSafe(parts[0]), + parseFloatSafe(parts[1]), + parseFloatSafe(parts[2])) + } + } + } + return "N/A" +} + +func parseFloatSafe(s string) float64 { + if f, err := strconv.ParseFloat(s, 64); err == nil { + return f + } + return 0 +} + +func getMemoryStats(stats *SystemStats) { + if runtime.GOOS == "linux" { + if data, err := os.ReadFile("/proc/meminfo"); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "MemTotal") { + stats.MemTotal = parseMeminfoLine(line) + } else if strings.HasPrefix(line, "MemAvailable") || strings.HasPrefix(line, "MemFree") { + if stats.MemFree == 0 { + stats.MemFree = parseMeminfoLine(line) + } + } + } + if stats.MemFree > 0 && stats.MemTotal > 0 { + stats.MemUsed = stats.MemTotal - stats.MemFree + } + } + } +} + +func parseMeminfoLine(line string) int64 { + parts := strings.Fields(line) + if len(parts) >= 2 { + if val, err := strconv.ParseInt(parts[1], 10, 64); err == nil { + return val * 1024 + } + } + return 0 +} + +func getDiskStats(stats *SystemStats) { + if runtime.GOOS == "linux" { + output, err := exec.Command("df", "-k", "/").Output() + if err == nil { + lines := strings.Split(string(output), "\n") + if len(lines) >= 2 { + parts := strings.Fields(lines[1]) + if len(parts) >= 4 { + stats.DiskTotal, _ = strconv.ParseInt(parts[1], 10, 64) + stats.DiskUsed, _ = strconv.ParseInt(parts[2], 10, 64) + stats.DiskFree, _ = strconv.ParseInt(parts[3], 10, 64) + stats.DiskTotal *= 1024 + stats.DiskUsed *= 1024 + stats.DiskFree *= 1024 + } + } + } + } +} + +func getTopProcs(count int) []ProcInfo { + var procs []ProcInfo + + if runtime.GOOS == "linux" { + dir, err := os.Open("/proc") + if err != nil { + return procs + } + defer dir.Close() + + entries, err := dir.Readdirnames(count * 3) + if err != nil { + return procs + } + + for _, entry := range entries { + pid, err := strconv.Atoi(entry) + if err != nil { + continue + } + + stat, err := readProcStat(pid) + if err != nil { + continue + } + + procs = append(procs, stat) + if len(procs) >= count { + break + } + } + } + + return procs +} + +func readProcStat(pid int) (ProcInfo, error) { + data, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return ProcInfo{}, err + } + + parts := strings.SplitN(string(data), " ", 4) + if len(parts) < 4 { + return ProcInfo{}, fmt.Errorf("invalid format") + } + + name := parts[1] + name = strings.Trim(name, "(") + name = strings.TrimRight(name, ")") + + comm, _ := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) + commName := strings.TrimSpace(string(comm)) + + var rusage syscall.Rusage + syscall.Getrusage(syscall.RUSAGE_SELF, &rusage) + + return ProcInfo{ + PID: pid, + Name: commName, + CPU: 0, + Mem: 0, + Command: name, + }, nil +} + +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} diff --git a/cmd/usage.go b/cmd/usage.go new file mode 100644 index 0000000..e299468 --- /dev/null +++ b/cmd/usage.go @@ -0,0 +1,228 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/zemenawi/zutils/pkg/colors" + "github.com/zemenawi/zutils/pkg/formatter" +) + +type ProcessInfo struct { + PID int + Name string + Command string + CPUPercent float64 + MemBytes int64 + MemPercent float64 + Status string + User string +} + +func UsageCommand(args []string) error { + if len(args) < 1 { + return fmt.Errorf("missing process name\n\nUsage: z usage \n\nExamples:\n z usage cron # Show cron process usage\n z usage mongod # Show MongoDB usage\n z usage chrome # Show all Chrome processes") + } + + processName := args[0] + processes := findProcessesByName(processName) + + if len(processes) == 0 { + fmt.Printf("\n %sNo processes found matching '%s'%s\n\n", colors.Yellow, processName, colors.Reset) + fmt.Printf(" Try a different process name, or use %sz sys%s to see all processes\n\n", colors.Gray, colors.Reset) + return nil + } + + PrintBoxHeader(fmt.Sprintf("USAGE: %s (%d processes)", processName, len(processes)), colors.Cyan) + + totalCPU := 0.0 + totalMem := int64(0) + + for _, p := range processes { + totalCPU += p.CPUPercent + totalMem += p.MemBytes + } + + fmt.Printf("%s%sšŸ“Š Total CPU:%s %.1f%%\n", colors.Green, colors.Bold, colors.Reset, totalCPU) + fmt.Printf("%s%sšŸ’¾ Total Memory:%s %s (%.1f MB)\n", colors.Purple, colors.Bold, colors.Reset, formatter.FormatSize(totalMem), float64(totalMem)/1024/1024) + fmt.Println() + + headers := []string{"PID", "Process", "CPU%", "Memory", "Status"} + data := make([][]string, 0, len(processes)) + + sort.Slice(processes, func(i, j int) bool { + return processes[i].CPUPercent > processes[j].CPUPercent + }) + + for _, p := range processes { + cmd := p.Command + if len(cmd) > 30 { + parts := strings.Fields(cmd) + if len(parts) > 0 { + cmd = filepath.Base(parts[0]) + " " + strings.Join(parts[1:], " ")[:25] + } + } + + status := p.Status + if status == "R" { + status = colors.Green + "running" + colors.Reset + } else if status == "S" || status == "I" { + status = colors.Gray + "sleeping" + colors.Reset + } else { + status = colors.Yellow + status + colors.Reset + } + + row := []string{ + fmt.Sprintf("%d", p.PID), + cmd, + fmt.Sprintf("%.1f", p.CPUPercent), + formatter.FormatSize(p.MemBytes), + status, + } + data = append(data, row) + } + + formatter.PrintTable(headers, data, colors.Cyan) + fmt.Println() + + return nil +} + +func findProcessesByName(name string) []ProcessInfo { + var processes []ProcessInfo + + dir, err := os.Open("/proc") + if err != nil { + return processes + } + defer dir.Close() + + entries, err := dir.Readdirnames(500) + if err != nil { + return processes + } + + for _, entry := range entries { + pid, err := strconv.Atoi(entry) + if err != nil { + continue + } + + proc, err := getProcessInfo(pid, name) + if err == nil && proc != nil { + processes = append(processes, *proc) + } + } + + return processes +} + +func getProcessInfo(pid int, targetName string) (*ProcessInfo, error) { + comm, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)) + if err != nil { + return nil, err + } + procName := strings.TrimSpace(string(comm)) + + if !strings.Contains(strings.ToLower(procName), strings.ToLower(targetName)) { + if !strings.Contains(strings.ToLower(procName), strings.ToLower(targetName)) { + cmdline, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + if cmdline != nil && !strings.Contains(strings.ToLower(string(cmdline)), strings.ToLower(targetName)) { + return nil, fmt.Errorf("name mismatch") + } + } + } + + stat, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", pid)) + if err != nil { + return nil, err + } + + parts := strings.Split(string(stat), " ") + if len(parts) < 24 { + return nil, fmt.Errorf("invalid stat format") + } + + status := "?" + if len(parts) > 2 { + status = strings.Trim(parts[2], "()") + } + + utime := parseIntSafe(parts[13]) + stime := parseIntSafe(parts[14]) + + memBytes := int64(0) + if data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "VmRSS:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + memKB := parseIntSafe(fields[1]) + memBytes = memKB * 1024 + } + break + } + } + } + + totalMem := int64(0) + if data, err := os.ReadFile("/proc/meminfo"); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "MemTotal:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + totalMem = parseIntSafe(fields[1]) * 1024 + } + break + } + } + } + + cpuPercent := 0.0 + if totalMem > 0 { + cpuPercent = float64(utime+stime) / float64(totalMem) * 100 + } + + cmdline, _ := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) + cmdStr := strings.Join(strings.Fields(string(cmdline)), " ") + + return &ProcessInfo{ + PID: pid, + Name: procName, + Command: cmdStr, + CPUPercent: cpuPercent, + MemBytes: memBytes, + MemPercent: float64(memBytes) / float64(totalMem) * 100, + Status: status, + User: getProcessUser(pid), + }, nil +} + +func getProcessUser(pid int) string { + if data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid)); err == nil { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + if strings.HasPrefix(line, "Uid:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + uid := parseIntSafe(fields[1]) + return fmt.Sprintf("uid:%d", uid) + } + } + } + } + return "?" +} + +func parseIntSafe(s string) int64 { + if i, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64); err == nil { + return i + } + return 0 +} diff --git a/go.mod b/go.mod index 733884d..c26dddc 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,6 @@ go 1.22.0 require ( github.com/alecthomas/chroma/v2 v2.24.1 // indirect github.com/dlclark/regexp2 v1.12.0 // indirect + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect github.com/yuin/goldmark v1.8.2 // indirect ) diff --git a/go.sum b/go.sum index 5884727..4e0a69b 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,14 @@ github.com/alecthomas/chroma/v2 v2.24.1 h1:m5ffpfZbIb++k8AqFEKy9uVgY12xIQtBsQlc6DfZJQM= github.com/alecthomas/chroma/v2 v2.24.1/go.mod h1:l+ohZ9xRXIbGe7cIW+YZgOGbvuVLjMps/FYN/CwuabI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 1de7dce..dcd553c 100644 --- a/main.go +++ b/main.go @@ -3,41 +3,61 @@ package main import ( "fmt" "os" + "strings" "github.com/zemenawi/zutils/cmd" + "github.com/zemenawi/zutils/pkg/colors" "github.com/zemenawi/zutils/pkg/types" ) func main() { registry := types.NewCommandRegistry() - // Register all commands registry.Register(&types.Command{ Name: "read", - Description: "Read and display file with syntax highlighting", + Description: "Read and display file with smart detection", Handler: cmd.ReadCommand, }) + registry.Register(&types.Command{ - Name: "md", - Description: "Render markdown file to terminal", - Handler: cmd.MarkdownCommand, + Name: "sys", + Description: "Display system information overview", + Handler: cmd.SystemCommand, }) + + registry.Register(&types.Command{ + Name: "find", + Description: "Find files with pattern and filter support", + Handler: cmd.FindCommand, + }) + + registry.Register(&types.Command{ + Name: "search", + Description: "Search for text pattern in files", + Handler: cmd.SearchCommand, + }) + + registry.Register(&types.Command{ + Name: "usage", + Description: "Show resource usage for a process", + Handler: cmd.UsageCommand, + }) + cmd.RegisterInfoCommands(registry) + registry.Register(&types.Command{ Name: "version", Description: "Show version information", Handler: cmd.VersionCommand, }) - // Parse command line arguments args := os.Args[1:] - + if len(args) == 0 { printUsage(registry) os.Exit(1) } - // Handle legacy 'info' subcommand pattern for backwards compatibility if args[0] == "info" { infoCmd, exists := registry.Get("info") if !exists { @@ -46,48 +66,62 @@ func main() { } err := infoCmd.Handler(args[1:]) if err != nil { - fmt.Printf("Error: %v\n", err) + cmd.PrintErrorAndHelp("info", err) os.Exit(1) } return } - // Direct command: z info commandName := args[0] cmdArgs := args[1:] if command, exists := registry.Get(commandName); exists { err := command.Handler(cmdArgs) if err != nil { - fmt.Printf("Error: %v\n", err) + cmd.PrintErrorAndHelp(commandName, err) os.Exit(1) } } else { - // If no command matches, try auto-detect as info command - err := cmd.InfoCommand(args) - if err != nil { - fmt.Printf("Error: %v\n", err) - printUsage(registry) - os.Exit(1) + fmt.Printf("\n %sCommand not found: %s%s\n\n", colors.Red, args[0], colors.Reset) + fmt.Printf(" Did you mean one of these?\n\n") + suggestions := findSimilarCommands(args[0], registry.GetAll()) + for _, s := range suggestions { + fmt.Printf(" %s%s%s - %s\n", colors.Yellow, s.Name, colors.Reset, s.Description) } + fmt.Printf("\n") + printUsage(registry) + os.Exit(1) } } func printUsage(registry *types.CommandRegistry) { - fmt.Println("z - User-friendly terminal utilities") - fmt.Println() - fmt.Println("Usage: z [arguments]") - fmt.Println() - fmt.Println("Commands:") + fmt.Printf("\n %s%sz%s - User-friendly terminal utilities\n\n", colors.Bold, colors.Cyan, colors.Reset) + fmt.Printf(" %sUsage:%s z [arguments]\n\n", colors.Bold, colors.Reset) + fmt.Printf(" %sAvailable Commands:%s\n", colors.Bold, colors.Reset) + for _, command := range registry.GetAll() { - fmt.Printf(" %-12s %s\n", command.Name, command.Description) + fmt.Printf(" %s%-10s%s %s\n", colors.Green, command.Name, colors.Reset, command.Description) } - fmt.Println() - fmt.Println("Quick Examples:") - fmt.Println(" z read go.mod # Display file with line numbers") - fmt.Println(" z info main.go # File information") - fmt.Println(" z info . # Directory information") - fmt.Println(" z info network # Network information") - fmt.Println(" z main.go # Auto-detect file info") - fmt.Println() + + fmt.Printf("\n %sQuick Examples:%s\n", colors.Bold, colors.Reset) + fmt.Printf(" %sz read README.md%s # Display file with smart formatting\n", colors.Gray, colors.Reset) + fmt.Printf(" %sz sys%s # Show system overview\n", colors.Gray, colors.Reset) + fmt.Printf(" %sz find . -name '*.go'%s # Find files\n", colors.Gray, colors.Reset) + fmt.Printf(" %sz search pattern path%s # Search in files\n", colors.Gray, colors.Reset) + fmt.Printf(" %sz info .%s # Directory info with gitignore support\n", colors.Gray, colors.Reset) + fmt.Printf(" %sz info main.go%s # File information\n", colors.Gray, colors.Reset) + fmt.Printf("\n Type %sz %s without arguments to see help\n\n", colors.Gray, colors.Reset) +} + +func findSimilarCommands(input string, commands []*types.Command) []*types.Command { + var suggestions []*types.Command + for _, cmd := range commands { + if strings.HasPrefix(cmd.Name, input[:1]) { + suggestions = append(suggestions, cmd) + } + } + if len(suggestions) > 3 { + suggestions = suggestions[:3] + } + return suggestions } diff --git a/pkg/formatter/utils.go b/pkg/formatter/utils.go index a913fa4..56b5f70 100644 --- a/pkg/formatter/utils.go +++ b/pkg/formatter/utils.go @@ -1,11 +1,85 @@ package formatter import ( + "bytes" "fmt" "math" "os" + "path/filepath" + "strings" + + "github.com/alecthomas/chroma/v2/formatters" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" ) +type FileHandler func(args []string) error + +type FileHandlerRegistry struct { + handlers map[string]FileHandler +} + +var ReadHandlers = &FileHandlerRegistry{ + handlers: make(map[string]FileHandler), +} + +func (r *FileHandlerRegistry) Register(ext string, handler FileHandler) { + r.handlers[ext] = handler +} + +func (r *FileHandlerRegistry) GetHandler(filename string) FileHandler { + ext := filepath.Ext(filename) + + if ext == "" || ext == "." { + basename := filepath.Base(filename) + if strings.HasPrefix(basename, "Dockerfile") { + return r.handlers[".dockerfile"] + } + return nil + } + + if handler, ok := r.handlers[ext]; ok { + return handler + } + + return nil +} + +func ReadFileWithHighlight(file *os.File, filePath string) error { + content := ReadFileContent(file) + + lexer := lexers.Match(filepath.Base(filePath)) + if lexer == nil { + lexer = lexers.Fallback + } + + iterator, err := lexer.Tokenise(nil, content) + if err != nil { + return fmt.Errorf("error tokenizing: %w", err) + } + + formatter_ := formatters.TTY256 + style := styles.Get("monokai") + if style == nil { + style = styles.Fallback + } + + var buf bytes.Buffer + err = formatter_.Format(&buf, style, iterator) + if err != nil { + return fmt.Errorf("error formatting: %w", err) + } + + lines := strings.Split(buf.String(), "\n") + for i, line := range lines { + if line != "" { + fmt.Printf("%s%4d: %s%s\n", "\033[90m", i+1, "\033[0m", line) + } + } + + return nil +} + // FormatSize converts bytes to human-readable format func FormatSize(bytes int64) string { if bytes == 0 { @@ -29,11 +103,11 @@ func CenterText(text string, width int) string { if len(text) >= width { return text[:width] } - + padding := width - len(text) left := padding / 2 right := padding - left - + return fmt.Sprintf("%s%s%s", repeat(" ", left), text, repeat(" ", right)) } diff --git a/pkg/types/registry.go b/pkg/types/registry.go index b395b99..33f56c5 100644 --- a/pkg/types/registry.go +++ b/pkg/types/registry.go @@ -1,36 +1,30 @@ package types -// Command represents a CLI command type Command struct { Name string Description string Handler func(args []string) error } -// CommandRegistry manages available commands type CommandRegistry struct { commands map[string]*Command } -// NewCommandRegistry creates a new command registry func NewCommandRegistry() *CommandRegistry { return &CommandRegistry{ commands: make(map[string]*Command), } } -// Register adds a command to the registry func (r *CommandRegistry) Register(cmd *Command) { r.commands[cmd.Name] = cmd } -// Get retrieves a command by name func (r *CommandRegistry) Get(name string) (*Command, bool) { cmd, exists := r.commands[name] return cmd, exists } -// GetAll returns all registered commands func (r *CommandRegistry) GetAll() []*Command { cmds := make([]*Command, 0, len(r.commands)) for _, cmd := range r.commands { @@ -39,7 +33,6 @@ func (r *CommandRegistry) GetAll() []*Command { return cmds } -// Has checks if a command exists func (r *CommandRegistry) Has(name string) bool { _, exists := r.commands[name] return exists