1const { readFile } = require('fs/promises')
2const path = require('path')
3const { glob } = require('glob')
4const BaseCommand = require('../base-command.js')
5
6const globify = pattern => pattern.split('\\').join('/')
7
8class HelpSearch extends BaseCommand {
9  static description = 'Search npm help documentation'
10  static name = 'help-search'
11  static usage = ['<text>']
12  static params = ['long']
13
14  async exec (args) {
15    if (!args.length) {
16      throw this.usageError()
17    }
18
19    const docPath = path.resolve(this.npm.npmRoot, 'docs/content')
20    let files = await glob(`${globify(docPath)}/*/*.md`)
21    // preserve glob@8 behavior
22    files = files.sort((a, b) => a.localeCompare(b, 'en'))
23    const data = await this.readFiles(files)
24    const results = await this.searchFiles(args, data, files)
25    const formatted = this.formatResults(args, results)
26    if (!formatted.trim()) {
27      this.npm.output(`No matches in help for: ${args.join(' ')}\n`)
28    } else {
29      this.npm.output(formatted)
30    }
31  }
32
33  async readFiles (files) {
34    const res = {}
35    await Promise.all(files.map(async file => {
36      res[file] = (await readFile(file, 'utf8'))
37        .replace(/^---\n(.*\n)*?---\n/, '').trim()
38    }))
39    return res
40  }
41
42  async searchFiles (args, data, files) {
43    const results = []
44    for (const [file, content] of Object.entries(data)) {
45      const lowerCase = content.toLowerCase()
46      // skip if no matches at all
47      if (!args.some(a => lowerCase.includes(a.toLowerCase()))) {
48        continue
49      }
50
51      const lines = content.split(/\n+/)
52
53      // if a line has a search term, then skip it and the next line.
54      // if the next line has a search term, then skip all 3
55      // otherwise, set the line to null.  then remove the nulls.
56      for (let i = 0; i < lines.length; i++) {
57        const line = lines[i]
58        const nextLine = lines[i + 1]
59        let match = false
60        if (nextLine) {
61          match = args.some(a =>
62            nextLine.toLowerCase().includes(a.toLowerCase()))
63          if (match) {
64            // skip over the next line, and the line after it.
65            i += 2
66            continue
67          }
68        }
69
70        match = args.some(a => line.toLowerCase().includes(a.toLowerCase()))
71
72        if (match) {
73          // skip over the next line
74          i++
75          continue
76        }
77
78        lines[i] = null
79      }
80
81      // now squish any string of nulls into a single null
82      const pruned = lines.reduce((l, r) => {
83        if (!(r === null && l[l.length - 1] === null)) {
84          l.push(r)
85        }
86
87        return l
88      }, [])
89
90      if (pruned[pruned.length - 1] === null) {
91        pruned.pop()
92      }
93
94      if (pruned[0] === null) {
95        pruned.shift()
96      }
97
98      // now count how many args were found
99      const found = {}
100      let totalHits = 0
101      for (const line of pruned) {
102        for (const arg of args) {
103          const hit = (line || '').toLowerCase()
104            .split(arg.toLowerCase()).length - 1
105
106          if (hit > 0) {
107            found[arg] = (found[arg] || 0) + hit
108            totalHits += hit
109          }
110        }
111      }
112
113      const cmd = 'npm help ' +
114        path.basename(file, '.md').replace(/^npm-/, '')
115      results.push({
116        file,
117        cmd,
118        lines: pruned,
119        found: Object.keys(found),
120        hits: found,
121        totalHits,
122      })
123    }
124
125    // sort results by number of results found, then by number of hits
126    // then by number of matching lines
127
128    // coverage is ignored here because the contents of results are
129    // nondeterministic due to either glob or readFiles or Object.entries
130    return results.sort(/* istanbul ignore next */ (a, b) =>
131      a.found.length > b.found.length ? -1
132      : a.found.length < b.found.length ? 1
133      : a.totalHits > b.totalHits ? -1
134      : a.totalHits < b.totalHits ? 1
135      : a.lines.length > b.lines.length ? -1
136      : a.lines.length < b.lines.length ? 1
137      : 0).slice(0, 10)
138  }
139
140  formatResults (args, results) {
141    const cols = Math.min(process.stdout.columns || Infinity, 80) + 1
142
143    const output = results.map(res => {
144      const out = [res.cmd]
145      const r = Object.keys(res.hits)
146        .map(k => `${k}:${res.hits[k]}`)
147        .sort((a, b) => a > b ? 1 : -1)
148        .join(' ')
149
150      out.push(' '.repeat((Math.max(1, cols - out.join(' ').length - r.length - 1))))
151      out.push(r)
152
153      if (!this.npm.config.get('long')) {
154        return out.join('')
155      }
156
157      out.unshift('\n\n')
158      out.push('\n')
159      out.push('-'.repeat(cols - 1) + '\n')
160      res.lines.forEach((line, i) => {
161        if (line === null || i > 3) {
162          return
163        }
164
165        const hilitLine = []
166        for (const arg of args) {
167          const finder = line.toLowerCase().split(arg.toLowerCase())
168          let p = 0
169          for (const f of finder) {
170            hilitLine.push(line.slice(p, p + f.length))
171            const word = line.slice(p + f.length, p + f.length + arg.length)
172            const hilit = this.npm.chalk.bgBlack.red(word)
173            hilitLine.push(hilit)
174            p += f.length + arg.length
175          }
176        }
177        out.push(hilitLine.join('') + '\n')
178      })
179
180      return out.join('')
181    }).join('\n')
182
183    const finalOut = results.length && !this.npm.config.get('long')
184      ? 'Top hits for ' + (args.map(JSON.stringify).join(' ')) + '\n' +
185      '—'.repeat(cols - 1) + '\n' +
186      output + '\n' +
187      '—'.repeat(cols - 1) + '\n' +
188      '(run with -l or --long to see more context)'
189      : output
190
191    return finalOut.trim()
192  }
193}
194module.exports = HelpSearch
195