|
1 | 1 | package cmd |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bufio" |
4 | 5 | "fmt" |
5 | 6 | "os" |
6 | 7 | "path/filepath" |
@@ -48,60 +49,103 @@ func (s *Script) IsExecutable() bool { |
48 | 49 | return isExecutableByOwner(fileInfo.Mode()) |
49 | 50 | } |
50 | 51 |
|
51 | | -func (s *Script) parse() error { |
52 | | - b, err := os.ReadFile(s.path) |
| 52 | +// ParseV2 returns the usage and help text for the script |
| 53 | +// function aims to return early and perform as little work as possible |
| 54 | +// to avoid reading the entire file and stay performant |
| 55 | +// with large script folders and files |
| 56 | +func (s *Script) ParseV2() (string, string, error) { |
| 57 | + log.Debugw("Parsing script", "path", s.path) |
| 58 | + file, err := os.Open(s.path) |
53 | 59 | if err != nil { |
54 | | - return err |
| 60 | + return "", "", err |
55 | 61 | } |
56 | | - |
57 | | - if strings.Contains(string(b), UsageKey) || strings.Contains(string(b), LegacyUsageKey) { |
58 | | - lines := strings.Split(string(b), "\n") |
59 | | - var linesStart int |
60 | | - for idx, line := range lines { |
61 | | - if strings.Contains(line, UsageKey) || strings.Contains(line, LegacyUsageKey) { |
62 | | - linesStart = idx |
| 62 | + defer file.Close() |
| 63 | + |
| 64 | + scanner := bufio.NewScanner(file) |
| 65 | + |
| 66 | + // Parse the script file for the usage and help text |
| 67 | + // Expected structure is: |
| 68 | + // #!/bin/bash |
| 69 | + // # USAGE: script.sh [options] <arg1> <arg2> |
| 70 | + // # This is the help text for the script |
| 71 | + // # It can span multiple lines |
| 72 | + // |
| 73 | + // echo 1 |
| 74 | + |
| 75 | + var usage, help string |
| 76 | + idx := 0 |
| 77 | + |
| 78 | + var helpArr []string |
| 79 | + |
| 80 | + startsWithComment := regexp.MustCompile(`^[/*\-#]+`) |
| 81 | + for scanner.Scan() { |
| 82 | + t := scanner.Text() |
| 83 | + log.Debugw("Parsing line", "line", t) |
| 84 | + // Skip the shebang line |
| 85 | + if idx == 0 && strings.HasPrefix(t, "#!") { |
| 86 | + log.Debugw("shebang", "line", t) |
| 87 | + idx++ |
| 88 | + continue |
| 89 | + } |
| 90 | + // Normally this is the usage line |
| 91 | + if idx == 1 { |
| 92 | + log.Debugw("likely usage", "line", t) |
| 93 | + if !startsWithComment.MatchString(t) { |
| 94 | + usage = "" |
| 95 | + help = "" |
63 | 96 | break |
| 97 | + } else { |
| 98 | + withoutCommentChars := strings.TrimLeft(t, "#/-*") |
| 99 | + regexes := []regexp.Regexp{ |
| 100 | + *regexp.MustCompile(`(USAGE|SUMMARY):`), |
| 101 | + *regexp.MustCompile(fmt.Sprintf(`(%s|%s)`, regexp.QuoteMeta(`$0`), regexp.QuoteMeta(filepath.Base(s.path)))), |
| 102 | + *regexp.MustCompile(`TOME_[A-Z_]+`), // ignore tome option flags |
| 103 | + } |
| 104 | + for _, r := range regexes { |
| 105 | + withoutCommentChars = r.ReplaceAllLiteralString(withoutCommentChars, "") |
| 106 | + } |
| 107 | + usage = strings.TrimSpace(withoutCommentChars) |
| 108 | + log.Debugw("usage", "usage", usage) |
| 109 | + idx++ |
64 | 110 | } |
65 | 111 | } |
66 | 112 |
|
67 | | - var helpEnds int |
68 | | - for idx, line := range lines[linesStart:] { |
69 | | - if line == "" { |
70 | | - helpEnds = idx + linesStart |
71 | | - break |
72 | | - } |
| 113 | + // Scan until we find an empty line |
| 114 | + if startsWithComment.MatchString(t) { |
| 115 | + t2 := strings.TrimSpace(strings.TrimLeft(t, "#/-*")) |
| 116 | + log.Debugw("help line", "line", t2) |
| 117 | + helpArr = append(helpArr, t2) |
| 118 | + idx++ |
| 119 | + continue |
| 120 | + } else { |
| 121 | + break |
73 | 122 | } |
74 | | - helpTextLines := lines[linesStart:helpEnds] |
75 | | - helpText := strings.Join(helpTextLines, "\n") |
| 123 | + } |
| 124 | + help = strings.Join(helpArr, "\n") |
76 | 125 |
|
77 | | - s.usage = strings.TrimSpace(strings.SplitN(lines[linesStart], ":", 2)[1]) |
78 | | - s.help = helpText |
| 126 | + return usage, help, nil |
| 127 | +} |
| 128 | + |
| 129 | +func (s *Script) parse() error { |
| 130 | + usage, help, err := s.ParseV2() |
| 131 | + if err != nil { |
| 132 | + return err |
79 | 133 | } |
| 134 | + s.usage = usage |
| 135 | + s.help = help |
| 136 | + |
80 | 137 | return nil |
81 | 138 | } |
82 | 139 |
|
83 | 140 | // Usage returns the usage string for the script |
84 | 141 | // after stripping out the script name or $0 |
85 | 142 | // this is done to reduce visual noise |
86 | 143 | func (s *Script) Usage() string { |
87 | | - baseUsage := s.usage |
88 | | - prefixes := []string{"$0", filepath.Base(s.path)} |
89 | | - for _, prefix := range prefixes { |
90 | | - baseUsage = strings.TrimPrefix(baseUsage, prefix) |
91 | | - } |
92 | | - baseUsage = strings.TrimSpace(baseUsage) |
93 | | - return dedent.Dedent(baseUsage) |
| 144 | + return dedent.Dedent(s.usage) |
94 | 145 | } |
95 | 146 |
|
96 | 147 | func (s *Script) Help() string { |
97 | | - lines := strings.Split(s.help, "\n") |
98 | | - var helpTextLines []string |
99 | | - toTrim := []string{"#", "//", "/\\*", "\\*/", "--"} |
100 | | - toTrimRegex := regexp.MustCompile(fmt.Sprintf("^(%s)+", strings.Join(toTrim, "|"))) |
101 | | - for _, line := range lines { |
102 | | - helpTextLines = append(helpTextLines, toTrimRegex.ReplaceAllString(line, "")) |
103 | | - } |
104 | | - return dedent.Dedent(strings.Join(helpTextLines, "\n")) |
| 148 | + return dedent.Dedent(s.help) |
105 | 149 | } |
106 | 150 |
|
107 | 151 | func (s *Script) PathWithoutRoot() string { |
|
0 commit comments