Skip to content

Commit 1c2a204

Browse files
authored
Merge pull request #1136 from chris-reeves/320/format-using-shfmt
Support format using shfmt (fixes #320)
2 parents 602eaf4 + 9d5d784 commit 1c2a204

File tree

17 files changed

+729
-8
lines changed

17 files changed

+729
-8
lines changed

.github/workflows/verify.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ jobs:
1414
steps:
1515
- uses: actions/checkout@v4
1616

17-
- name: Install shellcheck (used for testing)
18-
run: sudo apt-get install -y shellcheck
17+
- name: Install shellcheck and shfmt (used for testing)
18+
run: sudo apt-get install -y shellcheck shfmt
1919

2020
- uses: pnpm/action-setup@v2
2121
with:

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Bash Language Server
22

3-
Bash language server that brings an IDE-like experience for bash scripts to most editors. This is based on the [Tree Sitter parser][tree-sitter-bash] and supports [explainshell][explainshell] and [shellcheck][shellcheck].
3+
Bash language server that brings an IDE-like experience for bash scripts to most editors. This is based on the [Tree Sitter parser][tree-sitter-bash] and supports [explainshell][explainshell], [shellcheck][shellcheck] and [shfmt][shfmt].
44

55
Documentation around configuration variables can be found in the [config.ts](https://github.com/bash-lsp/bash-language-server/blob/main/server/src/config.ts) file.
66

@@ -15,6 +15,7 @@ Documentation around configuration variables can be found in the [config.ts](htt
1515
- Documentation for symbols on hover
1616
- Workspace symbols
1717
- Rename symbol
18+
- Format document
1819

1920
To be implemented:
2021

@@ -24,7 +25,11 @@ To be implemented:
2425

2526
### Dependencies
2627

27-
As a dependency, we recommend that you first install shellcheck [shellcheck][shellcheck] to enable linting: https://github.com/koalaman/shellcheck#installing . If shellcheck is installed, bash-language-server will automatically call it to provide linting and code analysis each time the file is updated (with debounce time or 500ms).
28+
As a dependency, we recommend that you first install shellcheck [shellcheck][shellcheck] to enable linting: https://github.com/koalaman/shellcheck#installing . If shellcheck is installed, bash-language-server will automatically call it to provide linting and code analysis each time the file is updated (with debounce time of 500ms).
29+
30+
If you want your shell scripts to be formatted consistently, you can install [shfmt][shfmt]. If
31+
`shfmt` is installed then your documents will be formatted whenever you take the 'format document'
32+
action. In most editors this can be configured to happen automatically when files are saved.
2833

2934
### Bash language server
3035

@@ -197,6 +202,7 @@ Please see [docs/development-guide][dev-guide] for more information.
197202
[sublime-text-lsp]: https://packagecontrol.io/packages/LSP-bash
198203
[explainshell]: https://explainshell.com/
199204
[shellcheck]: https://www.shellcheck.net/
205+
[shfmt]: https://github.com/mvdan/sh#shfmt
200206
[languageclient-neovim]: https://github.com/autozimu/LanguageClient-neovim
201207
[nvim-lspconfig]: https://github.com/neovim/nvim-lspconfig
202208
[vim-lsp]: https://github.com/prabirshrestha/vim-lsp

server/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Bash Language Server
22

3+
## 5.3.0
4+
5+
- Add support for formatting using shfmt (if installed). https://github.com/bash-lsp/bash-language-server/pull/1136
6+
37
## 5.2.0
48

59
- Upgrade tree-sitter-bash from 0.20.7 to 0.22.5 https://github.com/bash-lsp/bash-language-server/pull/1148

server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"description": "A language server for Bash",
44
"author": "Mads Hartmann",
55
"license": "MIT",
6-
"version": "5.2.0",
6+
"version": "5.3.0",
77
"main": "./out/server.js",
88
"typings": "./out/server.d.ts",
99
"bin": {

server/src/__tests__/__snapshots__/server.test.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,7 @@ exports[`server onRenameRequest Workspace-wide rename returns correct WorkspaceE
909909
"file://__REPO_ROOT_FOLDER__/testing/fixtures/shellcheck/shell-directive.bash": [],
910910
"file://__REPO_ROOT_FOLDER__/testing/fixtures/shellcheck/source.sh": [],
911911
"file://__REPO_ROOT_FOLDER__/testing/fixtures/shellcheck/sourced.sh": [],
912+
"file://__REPO_ROOT_FOLDER__/testing/fixtures/shfmt.sh": [],
912913
"file://__REPO_ROOT_FOLDER__/testing/fixtures/sourcing.sh": [],
913914
"file://__REPO_ROOT_FOLDER__/testing/fixtures/sourcing2.sh": [],
914915
},
@@ -1001,6 +1002,7 @@ exports[`server onRenameRequest Workspace-wide rename returns correct WorkspaceE
10011002
"file://__REPO_ROOT_FOLDER__/testing/fixtures/shellcheck/shell-directive.bash": [],
10021003
"file://__REPO_ROOT_FOLDER__/testing/fixtures/shellcheck/source.sh": [],
10031004
"file://__REPO_ROOT_FOLDER__/testing/fixtures/shellcheck/sourced.sh": [],
1005+
"file://__REPO_ROOT_FOLDER__/testing/fixtures/shfmt.sh": [],
10041006
"file://__REPO_ROOT_FOLDER__/testing/fixtures/sourcing.sh": [],
10051007
"file://__REPO_ROOT_FOLDER__/testing/fixtures/sourcing2.sh": [],
10061008
},

server/src/__tests__/analyzer.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { Logger } from '../util/logger'
1616
const CURRENT_URI = 'dummy-uri.sh'
1717

1818
// if you add a .sh file to testing/fixtures, update this value
19-
const FIXTURE_FILES_MATCHING_GLOB = 18
19+
const FIXTURE_FILES_MATCHING_GLOB = 19
2020

2121
const defaultConfig = getDefaultConfiguration()
2222

server/src/__tests__/config.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ describe('ConfigSchema', () => {
1313
"logLevel": "info",
1414
"shellcheckArguments": [],
1515
"shellcheckPath": "shellcheck",
16+
"shfmt": {
17+
"binaryNextLine": false,
18+
"caseIndent": false,
19+
"funcNextLine": false,
20+
"path": "shfmt",
21+
"spaceRedirects": false,
22+
},
1623
}
1724
`)
1825
})
@@ -25,6 +32,13 @@ describe('ConfigSchema', () => {
2532
includeAllWorkspaceSymbols: true,
2633
shellcheckArguments: ' -e SC2001 -e SC2002 ',
2734
shellcheckPath: '',
35+
shfmt: {
36+
binaryNextLine: true,
37+
caseIndent: true,
38+
funcNextLine: true,
39+
path: 'myshfmt',
40+
spaceRedirects: true,
41+
},
2842
}),
2943
).toMatchInlineSnapshot(`
3044
{
@@ -41,6 +55,13 @@ describe('ConfigSchema', () => {
4155
"SC2002",
4256
],
4357
"shellcheckPath": "",
58+
"shfmt": {
59+
"binaryNextLine": true,
60+
"caseIndent": true,
61+
"funcNextLine": true,
62+
"path": "myshfmt",
63+
"spaceRedirects": true,
64+
},
4465
}
4566
`)
4667
})
@@ -67,12 +88,20 @@ describe('getConfigFromEnvironmentVariables', () => {
6788
"logLevel": "info",
6889
"shellcheckArguments": [],
6990
"shellcheckPath": "shellcheck",
91+
"shfmt": {
92+
"binaryNextLine": false,
93+
"caseIndent": false,
94+
"funcNextLine": false,
95+
"path": "shfmt",
96+
"spaceRedirects": false,
97+
},
7098
}
7199
`)
72100
})
73101
it('preserves an empty string', () => {
74102
process.env = {
75103
SHELLCHECK_PATH: '',
104+
SHFMT_PATH: '',
76105
EXPLAINSHELL_ENDPOINT: '',
77106
}
78107
const { config } = getConfigFromEnvironmentVariables()
@@ -86,6 +115,13 @@ describe('getConfigFromEnvironmentVariables', () => {
86115
"logLevel": "info",
87116
"shellcheckArguments": [],
88117
"shellcheckPath": "",
118+
"shfmt": {
119+
"binaryNextLine": false,
120+
"caseIndent": false,
121+
"funcNextLine": false,
122+
"path": "",
123+
"spaceRedirects": false,
124+
},
89125
}
90126
`)
91127
})
@@ -94,6 +130,8 @@ describe('getConfigFromEnvironmentVariables', () => {
94130
process.env = {
95131
SHELLCHECK_PATH: '/path/to/shellcheck',
96132
SHELLCHECK_ARGUMENTS: '-e SC2001',
133+
SHFMT_PATH: '/path/to/shfmt',
134+
SHFMT_CASE_INDENT: 'true',
97135
EXPLAINSHELL_ENDPOINT: 'localhost:8080',
98136
GLOB_PATTERN: '*.*',
99137
BACKGROUND_ANALYSIS_MAX_FILES: '1',
@@ -113,6 +151,13 @@ describe('getConfigFromEnvironmentVariables', () => {
113151
"SC2001",
114152
],
115153
"shellcheckPath": "/path/to/shellcheck",
154+
"shfmt": {
155+
"binaryNextLine": false,
156+
"caseIndent": true,
157+
"funcNextLine": false,
158+
"path": "/path/to/shfmt",
159+
"spaceRedirects": false,
160+
},
116161
}
117162
`)
118163
})

server/src/__tests__/server.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ describe('server', () => {
8585
],
8686
},
8787
"definitionProvider": true,
88+
"documentFormattingProvider": true,
8889
"documentHighlightProvider": true,
8990
"documentSymbolProvider": true,
9091
"hoverProvider": true,

server/src/config.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,25 @@ 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+
shfmt: z
45+
.object({
46+
// Controls the executable used for Shfmt formatting. An empty string will disable formatting
47+
path: z.string().trim().default('shfmt'),
48+
49+
// Allow boolean operators (like && and ||) to start a line.
50+
binaryNextLine: z.boolean().default(false),
51+
52+
// Indent patterns in case statements.
53+
caseIndent: z.boolean().default(false),
54+
55+
// Place function opening braces on a separate line.
56+
funcNextLine: z.boolean().default(false),
57+
58+
// Follow redirection operators with a space.
59+
spaceRedirects: z.boolean().default(false),
60+
})
61+
.default({}),
4362
})
4463

4564
export type Config = z.infer<typeof ConfigSchema>
@@ -57,6 +76,13 @@ export function getConfigFromEnvironmentVariables(): {
5776
logLevel: process.env[LOG_LEVEL_ENV_VAR],
5877
shellcheckArguments: process.env.SHELLCHECK_ARGUMENTS,
5978
shellcheckPath: process.env.SHELLCHECK_PATH,
79+
shfmt: {
80+
path: process.env.SHFMT_PATH,
81+
binaryNextLine: toBoolean(process.env.SHFMT_BINARY_NEXT_LINE),
82+
caseIndent: toBoolean(process.env.SHFMT_CASE_INDENT),
83+
funcNextLine: toBoolean(process.env.SHFMT_FUNC_NEXT_LINE),
84+
spaceRedirects: toBoolean(process.env.SHFMT_SPACE_REDIRECTS),
85+
},
6086
}
6187

6288
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.shfmt?.path
283+
if (!shfmtPath) {
284+
logger.info('Shfmt formatting is disabled as "shfmt.path" 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, params.options, this.config.shfmt)
837+
} catch (err) {
838+
logger.error(`Error while formatting: ${err}`)
839+
}
840+
}
841+
842+
return null
843+
}
809844
}
810845

811846
/**

0 commit comments

Comments
 (0)