diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index b25e2c31b..4a3df3e94 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -1,6 +1,6 @@ name: Verify changes -on: [push, pull_request] +on: [pull_request] jobs: verify: diff --git a/server/CHANGELOG.md b/server/CHANGELOG.md index 4fa59f12e..6f255b686 100644 --- a/server/CHANGELOG.md +++ b/server/CHANGELOG.md @@ -1,5 +1,10 @@ # Bash Language Server +## 1.14.0 + +* onHover and onCompletion documentation improvements (https://github.com/bash-lsp/bash-language-server/pull/230) +* support 0/1 as values for `HIGHLIGHT_PARSING_ERRORS` (https://github.com/bash-lsp/bash-language-server/pull/231) + ## 1.13.1 * Gracefully handle glob failures (https://github.com/bash-lsp/bash-language-server/pull/224, https://github.com/bash-lsp/bash-language-server/pull/226) diff --git a/server/package.json b/server/package.json index 6b4f1a295..ac6f2b5f0 100644 --- a/server/package.json +++ b/server/package.json @@ -3,7 +3,7 @@ "description": "A language server for Bash", "author": "Mads Hartmann", "license": "MIT", - "version": "1.13.1", + "version": "1.14.0", "publisher": "mads-hartmann", "main": "./out/server.js", "typings": "./out/server.d.ts", diff --git a/server/src/__tests__/analyzer.test.ts b/server/src/__tests__/analyzer.test.ts index bb49f700d..55e254f43 100644 --- a/server/src/__tests__/analyzer.test.ts +++ b/server/src/__tests__/analyzer.test.ts @@ -107,8 +107,9 @@ describe('findSymbolCompletions', () => { analyzer.analyze('install.sh', FIXTURES.INSTALL) analyzer.analyze('sourcing-sh', FIXTURES.SOURCING) - expect(analyzer.findSymbolsMatchingWord({ word: 'npm_config_logl' })) - .toMatchInlineSnapshot(` + expect( + analyzer.findSymbolsMatchingWord({ word: 'npm_config_logl', exactMatch: false }), + ).toMatchInlineSnapshot(` Array [ Object { "kind": 13, @@ -181,13 +182,13 @@ describe('findSymbolCompletions', () => { ] `) - expect(analyzer.findSymbolsMatchingWord({ word: 'xxxxxxxx' })).toMatchInlineSnapshot( - `Array []`, - ) + expect( + analyzer.findSymbolsMatchingWord({ word: 'xxxxxxxx', exactMatch: false }), + ).toMatchInlineSnapshot(`Array []`) - expect(analyzer.findSymbolsMatchingWord({ word: 'BLU' })).toMatchInlineSnapshot( - `Array []`, - ) + expect( + analyzer.findSymbolsMatchingWord({ word: 'BLU', exactMatch: false }), + ).toMatchInlineSnapshot(`Array []`) }) }) diff --git a/server/src/__tests__/builtins.test.ts b/server/src/__tests__/builtins.test.ts deleted file mode 100644 index db51c9ef7..000000000 --- a/server/src/__tests__/builtins.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import * as Builtins from '../builtins' - -describe('documentation', () => { - it('returns an error string an unknown builtin', async () => { - const result = await Builtins.documentation('foobar') - expect(result).toEqual('No help page for foobar') - }) - - it('returns documentation string an known builtin', async () => { - const result = await Builtins.documentation('exit') - const firstLine = result.split('\n')[0] - expect(firstLine).toEqual('exit: exit [n]') - }) -}) diff --git a/server/src/__tests__/executables.test.ts b/server/src/__tests__/executables.test.ts index 0a5eaaf18..7d64bfccc 100644 --- a/server/src/__tests__/executables.test.ts +++ b/server/src/__tests__/executables.test.ts @@ -27,13 +27,6 @@ describe('list', () => { }) }) -describe('documentation', () => { - it('uses `man` so it disregards the PATH it has been initialized with', async () => { - const result = await executables.documentation('ls') - expect(result).toBeTruthy() - }) -}) - describe('isExecutableOnPATH', () => { it('looks at the PATH it has been initialized with', async () => { const result = executables.isExecutableOnPATH('ls') diff --git a/server/src/__tests__/server.test.ts b/server/src/__tests__/server.test.ts index 7b1570a93..43c159d37 100644 --- a/server/src/__tests__/server.test.ts +++ b/server/src/__tests__/server.test.ts @@ -71,7 +71,7 @@ describe('server', () => { expect(result).toEqual({ contents: { kind: 'markdown', - value: expect.stringContaining('RM(1)'), + value: expect.stringContaining('remove directories'), }, }) }) diff --git a/server/src/analyser.ts b/server/src/analyser.ts index 5a56c2f92..f15f50177 100644 --- a/server/src/analyser.ts +++ b/server/src/analyser.ts @@ -264,13 +264,20 @@ export default class Analyzer { /** * Find symbol completions for the given word. */ - public findSymbolsMatchingWord({ word }: { word: string }): LSP.SymbolInformation[] { + public findSymbolsMatchingWord({ + exactMatch, + word, + }: { + exactMatch: boolean + word: string + }): LSP.SymbolInformation[] { const symbols: LSP.SymbolInformation[] = [] Object.keys(this.uriToDeclarations).forEach(uri => { const declarationsInFile = this.uriToDeclarations[uri] || {} Object.keys(declarationsInFile).map(name => { - if (name.startsWith(word)) { + const match = exactMatch ? name === word : name.startsWith(word) + if (match) { declarationsInFile[name].forEach(symbol => symbols.push(symbol)) } }) diff --git a/server/src/builtins.ts b/server/src/builtins.ts index 7b5685b09..b5d560dc5 100644 --- a/server/src/builtins.ts +++ b/server/src/builtins.ts @@ -1,5 +1,3 @@ -import * as ShUtil from './util/sh' - // You can generate this list by running `compgen -b` in a bash session export const LIST = [ '.', @@ -68,13 +66,3 @@ const SET = new Set(LIST) export function isBuiltin(word: string): boolean { return SET.has(word) } - -export async function documentation(builtin: string): Promise { - const errorMessage = `No help page for ${builtin}` - try { - const doc = await ShUtil.execShellScript(`help ${builtin}`) - return doc || errorMessage - } catch (error) { - return errorMessage - } -} diff --git a/server/src/executables.ts b/server/src/executables.ts index 6d733d74b..d41d5587c 100644 --- a/server/src/executables.ts +++ b/server/src/executables.ts @@ -4,7 +4,6 @@ import { promisify } from 'util' import * as ArrayUtil from './util/array' import * as FsUtil from './util/fs' -import * as ShUtil from './util/sh' const lstatAsync = promisify(fs.lstat) const readdirAsync = promisify(fs.readdir) @@ -44,19 +43,6 @@ export default class Executables { public isExecutableOnPATH(executable: string): boolean { return this.executables.has(executable) } - - /** - * Look up documentation for the given executable. - * - * For now it simply tries to look up the MAN documentation. - */ - public documentation(executable: string): Promise { - return ShUtil.execShellScript(`man ${executable} | col -b`).then(doc => { - return !doc - ? Promise.resolve(`No MAN page for ${executable}`) - : Promise.resolve(doc) - }) - } } /** diff --git a/server/src/reservedWords.ts b/server/src/reservedWords.ts index 88d4629d0..e5f1a0a60 100644 --- a/server/src/reservedWords.ts +++ b/server/src/reservedWords.ts @@ -1,5 +1,3 @@ -import * as ShUtil from './util/sh' - // https://www.gnu.org/software/bash/manual/html_node/Reserved-Word-Index.html export const LIST = [ @@ -31,12 +29,3 @@ const SET = new Set(LIST) export function isReservedWord(word: string): boolean { return SET.has(word) } - -export async function documentation(reservedWord: string): Promise { - try { - const doc = await ShUtil.execShellScript(`help ${reservedWord}`) - return doc || '' - } catch (error) { - return '' - } -} diff --git a/server/src/server.ts b/server/src/server.ts index 5c6c8d835..07b23ced0 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -11,6 +11,7 @@ import { initializeParser } from './parser' import * as ReservedWords from './reservedWords' import { BashCompletionItem, CompletionItemDataType } from './types' import { uniqueBasedOnHash } from './util/array' +import { getShellDocumentation } from './util/sh' /** * The BashServer glues together the separate components to implement @@ -138,10 +139,11 @@ export default class BashServer { params: LSP.TextDocumentPositionParams, ): Promise { const word = this.getWordAtPoint(params) + const currentUri = params.textDocument.uri this.logRequest({ request: 'onHover', params, word }) - if (!word) { + if (!word || word.startsWith('#')) { return null } @@ -167,33 +169,41 @@ export default class BashServer { } } - const getMarkdownHoverItem = (doc: string) => ({ - // LSP.MarkupContent - value: ['``` man', doc, '```'].join('\n'), - // Passed as markdown for syntax highlighting - kind: 'markdown' as const, - }) - - if (Builtins.isBuiltin(word)) { - return Builtins.documentation(word).then(doc => ({ - contents: getMarkdownHoverItem(doc), - })) - } - - if (ReservedWords.isReservedWord(word)) { - return ReservedWords.documentation(word).then(doc => ({ - contents: getMarkdownHoverItem(doc), - })) - } + if ( + ReservedWords.isReservedWord(word) || + Builtins.isBuiltin(word) || + this.executables.isExecutableOnPATH(word) + ) { + const shellDocumentation = await getShellDocumentation({ word }) + if (shellDocumentation) { + // eslint-disable-next-line no-console + return { contents: getMarkdownContent(shellDocumentation) } + } + } else { + const symbolDocumentation = deduplicateSymbols({ + symbols: this.analyzer.findSymbolsMatchingWord({ + exactMatch: true, + word, + }), + currentUri, + }) + // do not return hover referencing for the current line + .filter(symbol => symbol.location.range.start.line !== params.position.line) + .map((symbol: LSP.SymbolInformation) => + symbol.location.uri !== currentUri + ? `${symbolKindToDescription(symbol.kind)} defined in ${path.relative( + currentUri, + symbol.location.uri, + )}` + : `${symbolKindToDescription(symbol.kind)} defined on line ${symbol.location + .range.start.line + 1}`, + ) - if (this.executables.isExecutableOnPATH(word)) { - return this.executables.documentation(word).then(doc => ({ - contents: getMarkdownHoverItem(doc), - })) + if (symbolDocumentation.length === 1) { + return { contents: symbolDocumentation[0] } + } } - // FIXME: could also be a symbol - return null } @@ -249,6 +259,7 @@ export default class BashServer { ? [] : getCompletionItemsForSymbols({ symbols: this.analyzer.findSymbolsMatchingWord({ + exactMatch: false, word, }), currentUri, @@ -315,42 +326,39 @@ export default class BashServer { this.connection.console.log(`onCompletionResolve name=${name} type=${type}`) - const getMarkdownCompletionItem = (doc: string) => ({ - ...item, - // LSP.MarkupContent - documentation: { - value: ['``` man', doc, '```'].join('\n'), - // Passed as markdown for syntax highlighting - kind: 'markdown' as const, - }, - }) - try { - if (type === CompletionItemDataType.Executable) { - const doc = await this.executables.documentation(name) - return getMarkdownCompletionItem(doc) - } else if (type === CompletionItemDataType.Builtin) { - const doc = await Builtins.documentation(name) - return getMarkdownCompletionItem(doc) - } else if (type === CompletionItemDataType.ReservedWord) { - const doc = await ReservedWords.documentation(name) - return getMarkdownCompletionItem(doc) - } else { - return item + let documentation = null + + if ( + type === CompletionItemDataType.Executable || + type === CompletionItemDataType.Builtin || + type === CompletionItemDataType.ReservedWord + ) { + documentation = await getShellDocumentation({ word: name }) } + + return documentation + ? { + ...item, + documentation: getMarkdownContent(documentation), + } + : item } catch (error) { return item } } } -function getCompletionItemsForSymbols({ +/** + * Deduplicate symbols by prioritizing the current file. + */ +function deduplicateSymbols({ symbols, currentUri, }: { symbols: LSP.SymbolInformation[] currentUri: string -}): BashCompletionItem[] { +}) { const isCurrentFile = ({ location: { uri } }: LSP.SymbolInformation) => uri === currentUri @@ -360,7 +368,7 @@ function getCompletionItemsForSymbols({ const symbolsOtherFiles = symbols .filter(s => !isCurrentFile(s)) - // Remove identical symbols + // Remove identical symbols matching current file .filter( symbolOtherFiles => !symbolsCurrentFile.some( @@ -369,24 +377,33 @@ function getCompletionItemsForSymbols({ ), ) - return uniqueBasedOnHash( - [...symbolsCurrentFile, ...symbolsOtherFiles], - getSymbolId, - ).map((symbol: LSP.SymbolInformation) => ({ - label: symbol.name, - kind: symbolKindToCompletionKind(symbol.kind), - data: { - name: symbol.name, - type: CompletionItemDataType.Symbol, - }, - documentation: - symbol.location.uri !== currentUri - ? `${symbolKindToDescription(symbol.kind)} defined in ${path.relative( - currentUri, - symbol.location.uri, - )}` - : undefined, - })) + return uniqueBasedOnHash([...symbolsCurrentFile, ...symbolsOtherFiles], getSymbolId) +} + +function getCompletionItemsForSymbols({ + symbols, + currentUri, +}: { + symbols: LSP.SymbolInformation[] + currentUri: string +}): BashCompletionItem[] { + return deduplicateSymbols({ symbols, currentUri }).map( + (symbol: LSP.SymbolInformation) => ({ + label: symbol.name, + kind: symbolKindToCompletionKind(symbol.kind), + data: { + name: symbol.name, + type: CompletionItemDataType.Symbol, + }, + documentation: + symbol.location.uri !== currentUri + ? `${symbolKindToDescription(symbol.kind)} defined in ${path.relative( + currentUri, + symbol.location.uri, + )}` + : undefined, + }), + ) } function symbolKindToCompletionKind(s: LSP.SymbolKind): LSP.CompletionItemKind { @@ -451,3 +468,9 @@ function symbolKindToDescription(s: LSP.SymbolKind): string { return 'Keyword' } } + +const getMarkdownContent = (documentation: string): LSP.MarkupContent => ({ + value: ['``` man', documentation, '```'].join('\n'), + // Passed as markdown for syntax highlighting + kind: 'markdown' as const, +}) diff --git a/server/src/util/__tests__/__snapshots__/sh.test.ts.snap b/server/src/util/__tests__/__snapshots__/sh.test.ts.snap new file mode 100644 index 000000000..b0cb93372 Binary files /dev/null and b/server/src/util/__tests__/__snapshots__/sh.test.ts.snap differ diff --git a/server/src/util/__tests__/sh.test.ts b/server/src/util/__tests__/sh.test.ts new file mode 100644 index 000000000..ad026fda4 --- /dev/null +++ b/server/src/util/__tests__/sh.test.ts @@ -0,0 +1,514 @@ +/* eslint-disable no-useless-escape */ +import * as sh from '../sh' + +describe('getDocumentation', () => { + it('returns null for an unknown builtin', async () => { + const result = await sh.getShellDocumentation({ word: 'foobar' }) + expect(result).toEqual(null) + }) + + it('returns documentation string for a known builtin', async () => { + const result = (await sh.getShellDocumentation({ word: 'exit' })) as string + const firstLine = result.split('\n')[0] + expect(firstLine).toEqual('exit: exit [n]') + }) + + it('returns documentation string (man page) for known command', async () => { + const result = (await sh.getShellDocumentation({ word: 'ls' })) as string + const lines = result.split('\n') + expect(lines[0]).toEqual('NAME') + expect(lines[1]).toContain('list directory contents') + }) + + it('sanity checks the given word', async () => { + await expect(sh.getShellDocumentation({ word: 'ls foo' })).rejects.toThrow() + }) +}) + +describe('formatManOutput', () => { + // The following were extracted using docker and by running the command: + // `MANWIDTH=45 man echo | cat` + + it('formats GNU (Ubuntu) manuals', () => { + expect( + sh.formatManOutput(`ECHO(1) User Commands ECHO(1) + +NAME + echo - display a line of text + +SYNOPSIS + echo [SHORT-OPTION]... [STRING]... + echo LONG-OPTION + +DESCRIPTION + Echo the STRING(s) to standard out- + put. + + -n do not output the trailing + newline + + -e enable interpretation of + backslash escapes + + -E disable interpretation of + backslash escapes (default) + + --help display this help and exit + + --version + output version information + and exit + + If -e is in effect, the following + sequences are recognized: + + \\ backslash + + \\a alert (BEL) + + \\b backspace + + \\c produce no further output + + \\e escape + + \\f form feed + + \\n new line + + \\r carriage return + + \\t horizontal tab + + \\v vertical tab + + \\0NNN byte with octal value NNN (1 + to 3 digits) + + \\xHH byte with hexadecimal value + HH (1 to 2 digits) + + NOTE: your shell may have its own + version of echo, which usually su- + persedes the version described here. + Please refer to your shell's docu- + mentation for details about the op- + tions it supports. + +AUTHOR + Written by Brian Fox and Chet Ramey. + +REPORTING BUGS + GNU coreutils online help: + + Report echo translation bugs to + + +COPYRIGHT + Copyright (C) 2018 Free Software + Foundation, Inc. License GPLv3+: + GNU GPL version 3 or later + . + This is free software: you are free + to change and redistribute it. + There is NO WARRANTY, to the extent + permitted by law. + +SEE ALSO + Full documentation at: + + or available locally via: info + '(coreutils) echo invocation' + +GNU coreutils 8September 2019 ECHO(1)`), + ).toMatchSnapshot() + }) + + it('formats POSIX (Centos) manuals', () => { + expect( + sh.formatManOutput(`ECHO(1P) POSIX Programmer's Manual ECHO(1P) + +PROLOG + This manual page is part of the + POSIX Programmer's Manual. The + Linux implementation of this inter- + face may differ (consult the corre- + sponding Linux manual page for + details of Linux behavior), or the + interface may not be implemented on + Linux. + +NAME + echo -- write arguments to standard + output + +SYNOPSIS + echo [string...] + +DESCRIPTION + The echo utility writes its argu- + ments to standard output, followed + by a . If there are no + arguments, only the is + written. + +OPTIONS + The echo utility shall not recognize + the "--" argument in the manner + specified by Guideline 10 of the + Base Definitions volume of + POSIX.1-2008, Section 12.2, Utility + Syntax Guidelines; "--" shall be + recognized as a string operand. + + Implementations shall not support + any options. + +OPERANDS + The following operands shall be sup- + ported: + + string A string to be written to + standard output. If the + first operand is -n, or if + any of the operands con- + tain a charac- + ter, the results are + implementation-defined. + + On XSI-conformant systems, + if the first operand is + -n, it shall be treated as + a string, not an option. + The following character + sequences shall be recog- + nized on XSI-conformant + systems within any of the + arguments: + + \\a Write an . + + \\b Write a + . + + \\c Suppress the that other- + wise follows the + final argument in + the output. All + characters follow- + ing the '\\c' in + the arguments + shall be ignored. + + \\f Write a . + + \\n Write a . + + \\r Write a . + + \\t Write a . + + \\v Write a . + + \\ Write a character. + + \0num Write an 8-bit + value that is the + zero, one, two, or + three-digit octal + number num. + +STDIN + Not used. + +INPUT FILES + None. + +ENVIRONMENT VARIABLES + The following environment variables + shall affect the execution of echo: + + LANG Provide a default value + for the internationaliza- + tion variables that are + unset or null. (See the + Base Definitions volume of + POSIX.1-2008, Section 8.2, + Internationalization Vari- + ables for the precedence + of internationalization + variables used to deter- + mine the values of locale + categories.) + + LC_ALL If set to a non-empty + string value, override the + values of all the other + internationalization vari- + ables. + + LC_CTYPE Determine the locale for + the interpretation of + sequences of bytes of text + data as characters (for + example, single-byte as + opposed to multi-byte + characters in arguments). + + LC_MESSAGES + Determine the locale that + should be used to affect + the format and contents of + diagnostic messages writ- + ten to standard error. + + NLSPATH Determine the location of + message catalogs for the + processing of LC_MESSAGES. + +ASYNCHRONOUS EVENTS + Default. + +STDOUT + The echo utility arguments shall be + separated by single charac- + ters and a character shall + follow the last argument. Output + transformations shall occur based on + the escape sequences in the input. + See the OPERANDS section. + +STDERR + The standard error shall be used + only for diagnostic messages. + +OUTPUT FILES + None. + +EXTENDED DESCRIPTION + None. + +EXIT STATUS + The following exit values shall be + returned: + + 0 Successful completion. + + >0 An error occurred. + +CONSEQUENCES OF ERRORS + Default. + + The following sections are informa- + tive. + +APPLICATION USAGE + It is not possible to use echo + portably across all POSIX systems + unless both -n (as the first argu- + ment) and escape sequences are omit- + ted. + + The printf utility can be used + portably to emulate any of the tra- + ditional behaviors of the echo util- + ity as follows (assuming that IFS + has its standard value or is unset): + + * The historic System V echo and + the requirements on XSI imple- + mentations in this volume of + POSIX.1-2008 are equivalent to: + + printf "%b\n$*" + + * The BSD echo is equivalent to: + + if [ "X$1" = "X-n" ] + then + shift + printf "%s$*" + else + printf "%s\n$*" + fi + + New applications are encouraged to + use printf instead of echo. + +EXAMPLES + None. + +RATIONALE + The echo utility has not been made + obsolescent because of its extremely + widespread use in historical appli- + cations. Conforming applications + that wish to do prompting without + characters or that could + possibly be expecting to echo a -n, + should use the printf utility + derived from the Ninth Edition sys- + tem. + + As specified, echo writes its argu- + ments in the simplest of ways. The + two different historical versions of + echo vary in fatally incompatible + ways. + + The BSD echo checks the first argu- + ment for the string -n which causes + it to suppress the that + would otherwise follow the final + argument in the output. + + The System V echo does not support + any options, but allows escape + sequences within its operands, as + described for XSI implementations in + the OPERANDS section. + + The echo utility does not support + Utility Syntax Guideline 10 because + historical applications depend on + echo to echo all of its arguments, + except for the -n option in the BSD + version. + +FUTURE DIRECTIONS + None. + +SEE ALSO + printf + + The Base Definitions volume of + POSIX.1-2008, Chapter 8, Environment + Variables, Section 12.2, Utility + Syntax Guidelines + +COPYRIGHT + Portions of this text are reprinted + and reproduced in electronic form + from IEEE Std 1003.1, 2013 Edition, + Standard for Information Technology + -- Portable Operating System Inter- + face (POSIX), The Open Group Base + Specifications Issue 7, Copyright + (C) 2013 by the Institute of Elec- + trical and Electronics Engineers, + Inc and The Open Group. (This is + POSIX.1-2008 with the 2013 Technical + Corrigendum 1 applied.) In the event + of any discrepancy between this ver- + sion and the original IEEE and The + Open Group Standard, the original + IEEE and The Open Group Standard is + the referee document. The original + Standard can be obtained online at + http://www.unix.org/online.html . + + Any typographical or formatting + errors that appear in this page are + most likely to have been introduced + during the conversion of the source + files to man page format. To report + such errors, see https://www.ker- + nel.org/doc/man-pages/report- + ing_bugs.html . + +IEEE/The Open Group 2013 ECHO(1P)`), + ).toMatchSnapshot() + }) + + it('formats BSD (OS X) manuals', () => { + expect( + sh.formatManOutput(` + +ECHO(1) BSD General Commands Manual ECHO(1) + +NAME + echo -- write arguments to the + standard output + +SYNOPSIS + echo [-n] [string ...] + +DESCRIPTION + The echo utility writes any speci- + fied operands, separated by single + blank (\` ') characters and followed + by a newline (\`\\n') character, to + the standard output. + + The following option is available: + + -n Do not print the trailing + newline character. This may + also be achieved by appending + \`\\c' to the end of the + string, as is done by iBCS2 + compatible systems. Note + that this option as well as + the effect of \`\\c' are imple- + mentation-defined in IEEE Std + 1003.1-2001 (\`\`POSIX.1'') as + amended by Cor. 1-2002. + Applications aiming for maxi- + mum portability are strongly + encouraged to use printf(1) + to suppress the newline char- + acter. + + Some shells may provide a builtin + echo command which is similar or + identical to this utility. Most + notably, the builtin echo in sh(1) + does not accept the -n option. + Consult the builtin(1) manual page. + +EXIT STATUS + The echo utility exits 0 on suc- + cess, and >0 if an error occurs. + +SEE ALSO + builtin(1), csh(1), printf(1), + sh(1) + +STANDARDS + The echo utility conforms to IEEE + Std 1003.1-2001 (\`\`POSIX.1'') as + amended by Cor. 1-2002. + +BSD April 12, 2003 BSD`), + ).toMatchSnapshot() + }) +}) + +describe('memorize', () => { + it('memorizes a function', async () => { + const fnRaw = jest.fn(async args => args) + const arg1 = { one: '1' } + const arg2 = { another: { word: 'word' } } + const fnMemorized = sh.memorize(fnRaw) + + const arg1CallResult1 = await fnMemorized(arg1) + const arg1CallResult2 = await fnMemorized(arg1) + + const arg2CallResult1 = await fnMemorized(arg2) + const arg2CallResult2 = await fnMemorized(arg2) + + expect(fnRaw).toHaveBeenCalledTimes(2) + expect(fnRaw).toHaveBeenCalledWith(arg2) + + expect(arg1CallResult1).toBe(arg1CallResult2) + expect(arg2CallResult1).toBe(arg2CallResult2) + }) +}) diff --git a/server/src/util/sh.ts b/server/src/util/sh.ts index 36478ae15..46c31816c 100644 --- a/server/src/util/sh.ts +++ b/server/src/util/sh.ts @@ -23,3 +23,88 @@ export function execShellScript(body: string): Promise { }) }) } + +/** + * Get documentation for the given word by usingZZ help and man. + */ +export async function getShellDocumentationWithoutCache({ + word, +}: { + word: string +}): Promise { + if (word.split(' ').length > 1) { + throw new Error(`lookupDocumentation should be given a word, received "${word}"`) + } + + const DOCUMENTATION_COMMANDS = [ + { type: 'help', command: `help ${word} | col -bx` }, + // We have experimented with setting MANWIDTH to different values for reformatting. + // The default line width of the terminal works fine for hover, but could be better + // for completions. + { type: 'man', command: `man ${word} | col -bx` }, + ] + + for (const { type, command } of DOCUMENTATION_COMMANDS) { + try { + const documentation = await execShellScript(command) + if (documentation) { + let formattedDocumentation = documentation.trim() + + if (type === 'man') { + formattedDocumentation = formatManOutput(formattedDocumentation) + } + + return formattedDocumentation + } + } catch (error) { + // Ignoring if command fails and store failure in cache + console.error(`getShellDocumentation failed for "${word}"`, { error }) + } + } + + return null +} + +export function formatManOutput(manOutput: string): string { + const indexNameBlock = manOutput.indexOf('NAME') + const indexBeforeFooter = manOutput.lastIndexOf('\n') + + if (indexNameBlock < 0 || indexBeforeFooter < 0) { + return manOutput + } + + const formattedManOutput = manOutput.slice(indexNameBlock, indexBeforeFooter) + + if (!formattedManOutput) { + console.error(`formatManOutput failed`, { + manOutput, + }) + return manOutput + } + + return formattedManOutput +} + +/** + * Only works for one-parameter (serializable) functions. + */ +export function memorize(func: T): T { + const cache = new Map() + + const returnFunc = async function(arg: any) { + const cacheKey = JSON.stringify(arg) + + if (cache.has(cacheKey)) { + return cache.get(cacheKey) + } + + const result = await func(arg) + + cache.set(cacheKey, result) + return result + } + + return returnFunc as any +} + +export const getShellDocumentation = memorize(getShellDocumentationWithoutCache)