Skip to content

Commit 04670bf

Browse files
committed
Add basic support for formatting using shfmt
Limited to using shfmt's defaults for now - no way to tweak these.
1 parent b42d877 commit 04670bf

File tree

4 files changed

+142
-0
lines changed

4 files changed

+142
-0
lines changed

server/src/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ export const ConfigSchema = z.object({
4040

4141
// Controls the executable used for ShellCheck linting information. An empty string will disable linting.
4242
shellcheckPath: z.string().trim().default('shellcheck'),
43+
44+
// Controls the executable used for Shfmt formatting. An empty string will disable formatting
45+
shfmtPath: z.string().trim().default('shfmt'),
4346
})
4447

4548
export type Config = z.infer<typeof ConfigSchema>
@@ -57,6 +60,7 @@ export function getConfigFromEnvironmentVariables(): {
5760
logLevel: process.env[LOG_LEVEL_ENV_VAR],
5861
shellcheckArguments: process.env.SHELLCHECK_ARGUMENTS,
5962
shellcheckPath: process.env.SHELLCHECK_PATH,
63+
shfmtPath: process.env.SHFMT_PATH,
6064
}
6165

6266
const environmentVariablesUsed = Object.entries(rawConfig)

server/src/server.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import Executables from './executables'
1313
import { initializeParser } from './parser'
1414
import * as ReservedWords from './reserved-words'
1515
import { Linter, LintingResult } from './shellcheck'
16+
import { Formatter } from './shfmt'
1617
import { SNIPPETS } from './snippets'
1718
import { BashCompletionItem, CompletionItemDataType } from './types'
1819
import { uniqueBasedOnHash } from './util/array'
@@ -35,6 +36,7 @@ export default class BashServer {
3536
private documents: LSP.TextDocuments<TextDocument> = new LSP.TextDocuments(TextDocument)
3637
private executables: Executables
3738
private linter?: Linter
39+
private formatter?: Formatter
3840
private workspaceFolder: string | null
3941
private uriToCodeActions: {
4042
[uri: string]: LintingResult['codeActions'] | undefined
@@ -46,20 +48,23 @@ export default class BashServer {
4648
connection,
4749
executables,
4850
linter,
51+
formatter,
4952
workspaceFolder,
5053
}: {
5154
analyzer: Analyzer
5255
capabilities: LSP.ClientCapabilities
5356
connection: LSP.Connection
5457
executables: Executables
5558
linter?: Linter
59+
formatter?: Formatter
5660
workspaceFolder: string | null
5761
}) {
5862
this.analyzer = analyzer
5963
this.clientCapabilities = capabilities
6064
this.connection = connection
6165
this.executables = executables
6266
this.linter = linter
67+
this.formatter = formatter
6368
this.workspaceFolder = workspaceFolder
6469
this.config = {} as any // NOTE: configured in updateConfiguration
6570
this.updateConfiguration(config.getDefaultConfiguration(), true)
@@ -130,6 +135,7 @@ export default class BashServer {
130135
workDoneProgress: false,
131136
},
132137
renameProvider: { prepareProvider: true },
138+
documentFormattingProvider: true,
133139
}
134140
}
135141

@@ -172,6 +178,7 @@ export default class BashServer {
172178
connection.onWorkspaceSymbol(this.onWorkspaceSymbol.bind(this))
173179
connection.onPrepareRename(this.onPrepareRename.bind(this))
174180
connection.onRenameRequest(this.onRenameRequest.bind(this))
181+
connection.onDocumentFormatting(this.onDocumentFormatting.bind(this))
175182

176183
/**
177184
* The initialized notification is sent from the client to the server after
@@ -272,6 +279,14 @@ export default class BashServer {
272279
this.linter = new Linter({ executablePath: shellcheckPath })
273280
}
274281

282+
const { shfmtPath } = this.config
283+
if (!shfmtPath) {
284+
logger.info('Shfmt formatting is disabled as "shfmtPath" was not set')
285+
this.formatter = undefined
286+
} else {
287+
this.formatter = new Formatter({ executablePath: shfmtPath })
288+
}
289+
275290
this.analyzer.setEnableSourceErrorDiagnostics(
276291
this.config.enableSourceErrorDiagnostics,
277292
)
@@ -806,6 +821,26 @@ export default class BashServer {
806821
}
807822
return edits
808823
}
824+
825+
private async onDocumentFormatting(
826+
params: LSP.DocumentFormattingParams,
827+
): Promise<LSP.TextEdit[] | null> {
828+
if (this.formatter) {
829+
try {
830+
const document = this.documents.get(params.textDocument.uri)
831+
if (!document) {
832+
logger.error(`Error getting document: ${params.textDocument.uri}`)
833+
return null
834+
}
835+
836+
return await this.formatter.format(document)
837+
} catch (err) {
838+
logger.error(`Error while formatting: ${err}`)
839+
}
840+
}
841+
842+
return null
843+
}
809844
}
810845

811846
/**

server/src/shfmt/index.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { spawn } from 'child_process'
2+
import * as LSP from 'vscode-languageserver/node'
3+
import { TextDocument, TextEdit } from 'vscode-languageserver-textdocument'
4+
5+
import { logger } from '../util/logger'
6+
7+
type FormatterOptions = {
8+
executablePath: string
9+
cwd?: string
10+
}
11+
12+
export class Formatter {
13+
private cwd: string
14+
public executablePath: string
15+
private _canFormat: boolean
16+
17+
constructor({ cwd, executablePath }: FormatterOptions) {
18+
this._canFormat = true
19+
this.cwd = cwd || process.cwd()
20+
this.executablePath = executablePath
21+
}
22+
23+
public get canFormat(): boolean {
24+
return this._canFormat
25+
}
26+
27+
public async format(document: TextDocument): Promise<TextEdit[]> {
28+
if (!this._canFormat) {
29+
return []
30+
}
31+
32+
return this.executeFormat(document)
33+
}
34+
35+
private async executeFormat(document: TextDocument): Promise<TextEdit[]> {
36+
const documentText = document.getText()
37+
38+
const result = await this.runShfmt(documentText)
39+
40+
if (!this._canFormat) {
41+
return []
42+
}
43+
44+
return [
45+
{
46+
range: LSP.Range.create(
47+
LSP.Position.create(0, 0),
48+
LSP.Position.create(Number.MAX_VALUE, Number.MAX_VALUE),
49+
),
50+
newText: result,
51+
},
52+
]
53+
}
54+
55+
private async runShfmt(documentText: string): Promise<string> {
56+
const args: string[] = []
57+
58+
logger.debug(`Shfmt: running "${this.executablePath} ${args.join(' ')}"`)
59+
60+
let out = ''
61+
let err = ''
62+
const proc = new Promise((resolve, reject) => {
63+
const proc = spawn(this.executablePath, [...args, '-'], { cwd: this.cwd })
64+
proc.on('error', reject)
65+
proc.on('close', resolve)
66+
proc.stdout.on('data', (data) => (out += data))
67+
proc.stderr.on('data', (data) => (err += data))
68+
proc.stdin.on('error', () => {
69+
// NOTE: Ignore STDIN errors in case the process ends too quickly, before we try to
70+
// write. If we write after the process ends without this, we get an uncatchable EPIPE.
71+
// This is solved in Node >= 15.1 by the "on('spawn', ...)" event, but we need to
72+
// support earlier versions.
73+
})
74+
proc.stdin.end(documentText)
75+
})
76+
77+
// NOTE: do we care about exit code? 0 means "ok", 1 possibly means "errors",
78+
// but the presence of parseable errors in the output is also sufficient to
79+
// distinguish.
80+
let exit
81+
try {
82+
exit = await proc
83+
} catch (e) {
84+
// TODO: we could do this up front?
85+
if ((e as any).code === 'ENOENT') {
86+
// shfmt path wasn't found, don't try to format any more:
87+
logger.warn(
88+
`Shfmt: disabling formatting as no executable was found at path '${this.executablePath}'`,
89+
)
90+
this._canFormat = false
91+
return ''
92+
}
93+
throw new Error(`Shfmt: failed with code ${exit}: ${e}\nout:\n${out}\nerr:\n${err}`)
94+
}
95+
96+
return out
97+
}
98+
}

vscode-client/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@
7777
"type": "string",
7878
"default": "",
7979
"description": "Additional ShellCheck arguments. Note that we already add the following arguments: --shell, --format, --external-sources."
80+
},
81+
"bashIde.shfmtPath": {
82+
"type": "string",
83+
"default": "shfmt",
84+
"description": "Controls the executable used for Shfmt formatting. An empty string will disable formatting."
8085
}
8186
}
8287
}

0 commit comments

Comments
 (0)