ssh-config-ls 0.7.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ssh_config_ls-0.7.4/LICENSE +21 -0
- ssh_config_ls-0.7.4/PKG-INFO +145 -0
- ssh_config_ls-0.7.4/README.md +130 -0
- ssh_config_ls-0.7.4/pyproject.toml +60 -0
- ssh_config_ls-0.7.4/src/ssh_config_ls/__init__.py +34 -0
- ssh_config_ls-0.7.4/src/ssh_config_ls/cli.py +58 -0
- ssh_config_ls-0.7.4/src/ssh_config_ls/exceptions.py +13 -0
- ssh_config_ls-0.7.4/src/ssh_config_ls/formatter.py +161 -0
- ssh_config_ls-0.7.4/src/ssh_config_ls/lexer.py +249 -0
- ssh_config_ls-0.7.4/src/ssh_config_ls/lsp.py +73 -0
- ssh_config_ls-0.7.4/src/ssh_config_ls/parser.py +164 -0
- ssh_config_ls-0.7.4/src/ssh_config_ls/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nicolas Maignan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ssh-config-ls
|
|
3
|
+
Version: 0.7.4
|
|
4
|
+
Summary: An SSH config formatter, usable as a Language Server Protocol (LSP) plugin.
|
|
5
|
+
Author: Nicolas MAIGNAN
|
|
6
|
+
Author-email: Nicolas MAIGNAN <nicolas.maignan@proton.me>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: pygls>=2.1.1
|
|
10
|
+
Requires-Dist: typer>=0.26.7
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Project-URL: Documentation, https://ssh-config-formatter-2adfa2.gitlab.io
|
|
13
|
+
Project-URL: Repository, https://gitlab.com/nifra/ssh-config-ls
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
<!-- LTeX: language=en -->
|
|
17
|
+
|
|
18
|
+
# SSH config formatter
|
|
19
|
+
|
|
20
|
+
[](https://www.python.org/downloads/)
|
|
21
|
+
[](https://spdx.org/licenses/MIT.html)
|
|
22
|
+
|
|
23
|
+
[](https://docs.astral.sh/uv/)
|
|
24
|
+
[](https://docs.astral.sh/ruff/)
|
|
25
|
+
[](https://docs.astral.sh/ty/)
|
|
26
|
+
|
|
27
|
+
Little side project to start learning Rust and explore LSP.
|
|
28
|
+
|
|
29
|
+
## ✨ Features
|
|
30
|
+
|
|
31
|
+
- Formatting of SSH config files via the `sshfmt format` command
|
|
32
|
+
- Language server started with `sshfmt server`, following the LSP protocol
|
|
33
|
+
|
|
34
|
+
## 📦 Installation
|
|
35
|
+
|
|
36
|
+
You can install the tool easily with `pipx` or `uv`:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
uv tool install git+https://gitlab.com/nifra/ssh-config-ls
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## đź”§ Configuration
|
|
43
|
+
|
|
44
|
+
The language server accepts the following options from the client (e.g. Neovim):
|
|
45
|
+
|
|
46
|
+
```lua
|
|
47
|
+
{
|
|
48
|
+
formatter = {
|
|
49
|
+
indent = " ", -- Indentation for directives inside Host and Match blocks
|
|
50
|
+
separator = " ", -- Separator between a keyword and its arguments
|
|
51
|
+
sort_directives = true, -- Sort directives within blocks
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
<!-- LTeX: language=fr -->
|
|
59
|
+
|
|
60
|
+
## Formateur de config SSH — Plan de projet
|
|
61
|
+
|
|
62
|
+
### Étape 1 — Prototype Python
|
|
63
|
+
|
|
64
|
+
Objectif : valider la logique de parsing et de formatage sans se battre contre un nouveau langage.
|
|
65
|
+
|
|
66
|
+
**Note :** Couvrir les cas limites (commentaires inline, blocs Host/Match, lignes vides
|
|
67
|
+
multiples) et garder les tests sur le comportement observable (entrée/sortie texte) —
|
|
68
|
+
ils serviront de spec pour la réécriture Rust.
|
|
69
|
+
|
|
70
|
+
**Fonctionnalités à implémenter :**
|
|
71
|
+
|
|
72
|
+
**Lexer :**
|
|
73
|
+
|
|
74
|
+
- [x] Ajouter des tests sur le tokenizer
|
|
75
|
+
- [x] Implémenter un tokenizer pour le cœur de la config
|
|
76
|
+
- [x] Ajouter les commentaires dans le tokenizer
|
|
77
|
+
- [ ] Écrire tous les tests vicieux imaginables
|
|
78
|
+
|
|
79
|
+
**Parser :**
|
|
80
|
+
|
|
81
|
+
- [ ] Ajouter des tests sur le parser
|
|
82
|
+
- [x] Implémenter un parser
|
|
83
|
+
- [x] Parser les blocs `Host` et `Match`
|
|
84
|
+
- [ ] Écrire tous les tests vicieux imaginables
|
|
85
|
+
|
|
86
|
+
**Formatage :**
|
|
87
|
+
|
|
88
|
+
- [x] Préserver les commentaires et automatises les lignes vides
|
|
89
|
+
- [x] Normaliser l'indentation (4 espaces)
|
|
90
|
+
- [x] Canonicaliser la casse des clés (`hostname` → `Hostname`)
|
|
91
|
+
- [ ] Écrire tous les tests vicieux imaginables
|
|
92
|
+
|
|
93
|
+
**CLI :**
|
|
94
|
+
|
|
95
|
+
- [x] `sshls format <file>` — formate en place
|
|
96
|
+
- [ ] `sshls format --check <file>` — exit code non-zéro si non formaté (CI)
|
|
97
|
+
- [ ] `sshls format --diff <file>` — affiche le diff
|
|
98
|
+
|
|
99
|
+
**LSP :**
|
|
100
|
+
|
|
101
|
+
- [x] Lib : [pygls](https://pygls.readthedocs.io/en/latest/)
|
|
102
|
+
- [x] Créer le serveur
|
|
103
|
+
- [x] Ajouter textDocument/formatting
|
|
104
|
+
|
|
105
|
+
**Hors scope pour l'instant :**
|
|
106
|
+
|
|
107
|
+
- [ ] Gérer les directives `Include`
|
|
108
|
+
- [ ] Trier les blocs (`*` en dernier)
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
### Étape 2 — Apprentissage Rust
|
|
113
|
+
|
|
114
|
+
Avant de réécrire, poser les bases du langage.
|
|
115
|
+
|
|
116
|
+
- [ ] Lire les chapitres fondamentaux de [The Book](https://doc.rust-lang.org/book), en particulier sur l'ownership et les enums
|
|
117
|
+
- [ ] Faire quelques exercices [Rustlings](https://github.com/rust-lang/rustlings) pour la syntaxe
|
|
118
|
+
- [ ] Libs à connaître pour ce projet : `pest` ou `winnow` (parsing), `clap` (CLI)
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
### Étape 3 — Réécriture en Rust
|
|
123
|
+
|
|
124
|
+
Réécrire le formateur en Rust en s'appuyant sur les tests Python comme spec.
|
|
125
|
+
|
|
126
|
+
- [ ] Reprendre exactement les mêmes fonctionnalités et la même interface CLI
|
|
127
|
+
- [ ] Les tests Python guident l'implémentation et valident la parité de comportement
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
### Étape 4 — Serveur LSP
|
|
132
|
+
|
|
133
|
+
Transformer le formateur en serveur LSP avec uniquement la capacité de formatage (`textDocument/formatting`).
|
|
134
|
+
|
|
135
|
+
- [ ] Lib : [`tower-lsp`](https://github.com/ebkalderon/tower-lsp) pour abstraire le protocole JSON-RPC
|
|
136
|
+
- [ ] Intégration Neovim via `vim.lsp.start()` ou `nvim-lspconfig` — pas de plugin dédié nécessaire
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
### Références
|
|
141
|
+
|
|
142
|
+
- [The Rust Book](https://doc.rust-lang.org/book)
|
|
143
|
+
- [Rustlings](https://github.com/rust-lang/rustlings)
|
|
144
|
+
- [tower-lsp](https://github.com/ebkalderon/tower-lsp)
|
|
145
|
+
- [LSP Specification — textDocument/formatting](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_formatting)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<!-- LTeX: language=en -->
|
|
2
|
+
|
|
3
|
+
# SSH config formatter
|
|
4
|
+
|
|
5
|
+
[](https://www.python.org/downloads/)
|
|
6
|
+
[](https://spdx.org/licenses/MIT.html)
|
|
7
|
+
|
|
8
|
+
[](https://docs.astral.sh/uv/)
|
|
9
|
+
[](https://docs.astral.sh/ruff/)
|
|
10
|
+
[](https://docs.astral.sh/ty/)
|
|
11
|
+
|
|
12
|
+
Little side project to start learning Rust and explore LSP.
|
|
13
|
+
|
|
14
|
+
## ✨ Features
|
|
15
|
+
|
|
16
|
+
- Formatting of SSH config files via the `sshfmt format` command
|
|
17
|
+
- Language server started with `sshfmt server`, following the LSP protocol
|
|
18
|
+
|
|
19
|
+
## 📦 Installation
|
|
20
|
+
|
|
21
|
+
You can install the tool easily with `pipx` or `uv`:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
uv tool install git+https://gitlab.com/nifra/ssh-config-ls
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## đź”§ Configuration
|
|
28
|
+
|
|
29
|
+
The language server accepts the following options from the client (e.g. Neovim):
|
|
30
|
+
|
|
31
|
+
```lua
|
|
32
|
+
{
|
|
33
|
+
formatter = {
|
|
34
|
+
indent = " ", -- Indentation for directives inside Host and Match blocks
|
|
35
|
+
separator = " ", -- Separator between a keyword and its arguments
|
|
36
|
+
sort_directives = true, -- Sort directives within blocks
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
<!-- LTeX: language=fr -->
|
|
44
|
+
|
|
45
|
+
## Formateur de config SSH — Plan de projet
|
|
46
|
+
|
|
47
|
+
### Étape 1 — Prototype Python
|
|
48
|
+
|
|
49
|
+
Objectif : valider la logique de parsing et de formatage sans se battre contre un nouveau langage.
|
|
50
|
+
|
|
51
|
+
**Note :** Couvrir les cas limites (commentaires inline, blocs Host/Match, lignes vides
|
|
52
|
+
multiples) et garder les tests sur le comportement observable (entrée/sortie texte) —
|
|
53
|
+
ils serviront de spec pour la réécriture Rust.
|
|
54
|
+
|
|
55
|
+
**Fonctionnalités à implémenter :**
|
|
56
|
+
|
|
57
|
+
**Lexer :**
|
|
58
|
+
|
|
59
|
+
- [x] Ajouter des tests sur le tokenizer
|
|
60
|
+
- [x] Implémenter un tokenizer pour le cœur de la config
|
|
61
|
+
- [x] Ajouter les commentaires dans le tokenizer
|
|
62
|
+
- [ ] Écrire tous les tests vicieux imaginables
|
|
63
|
+
|
|
64
|
+
**Parser :**
|
|
65
|
+
|
|
66
|
+
- [ ] Ajouter des tests sur le parser
|
|
67
|
+
- [x] Implémenter un parser
|
|
68
|
+
- [x] Parser les blocs `Host` et `Match`
|
|
69
|
+
- [ ] Écrire tous les tests vicieux imaginables
|
|
70
|
+
|
|
71
|
+
**Formatage :**
|
|
72
|
+
|
|
73
|
+
- [x] Préserver les commentaires et automatises les lignes vides
|
|
74
|
+
- [x] Normaliser l'indentation (4 espaces)
|
|
75
|
+
- [x] Canonicaliser la casse des clés (`hostname` → `Hostname`)
|
|
76
|
+
- [ ] Écrire tous les tests vicieux imaginables
|
|
77
|
+
|
|
78
|
+
**CLI :**
|
|
79
|
+
|
|
80
|
+
- [x] `sshls format <file>` — formate en place
|
|
81
|
+
- [ ] `sshls format --check <file>` — exit code non-zéro si non formaté (CI)
|
|
82
|
+
- [ ] `sshls format --diff <file>` — affiche le diff
|
|
83
|
+
|
|
84
|
+
**LSP :**
|
|
85
|
+
|
|
86
|
+
- [x] Lib : [pygls](https://pygls.readthedocs.io/en/latest/)
|
|
87
|
+
- [x] Créer le serveur
|
|
88
|
+
- [x] Ajouter textDocument/formatting
|
|
89
|
+
|
|
90
|
+
**Hors scope pour l'instant :**
|
|
91
|
+
|
|
92
|
+
- [ ] Gérer les directives `Include`
|
|
93
|
+
- [ ] Trier les blocs (`*` en dernier)
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
### Étape 2 — Apprentissage Rust
|
|
98
|
+
|
|
99
|
+
Avant de réécrire, poser les bases du langage.
|
|
100
|
+
|
|
101
|
+
- [ ] Lire les chapitres fondamentaux de [The Book](https://doc.rust-lang.org/book), en particulier sur l'ownership et les enums
|
|
102
|
+
- [ ] Faire quelques exercices [Rustlings](https://github.com/rust-lang/rustlings) pour la syntaxe
|
|
103
|
+
- [ ] Libs à connaître pour ce projet : `pest` ou `winnow` (parsing), `clap` (CLI)
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
### Étape 3 — Réécriture en Rust
|
|
108
|
+
|
|
109
|
+
Réécrire le formateur en Rust en s'appuyant sur les tests Python comme spec.
|
|
110
|
+
|
|
111
|
+
- [ ] Reprendre exactement les mêmes fonctionnalités et la même interface CLI
|
|
112
|
+
- [ ] Les tests Python guident l'implémentation et valident la parité de comportement
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
### Étape 4 — Serveur LSP
|
|
117
|
+
|
|
118
|
+
Transformer le formateur en serveur LSP avec uniquement la capacité de formatage (`textDocument/formatting`).
|
|
119
|
+
|
|
120
|
+
- [ ] Lib : [`tower-lsp`](https://github.com/ebkalderon/tower-lsp) pour abstraire le protocole JSON-RPC
|
|
121
|
+
- [ ] Intégration Neovim via `vim.lsp.start()` ou `nvim-lspconfig` — pas de plugin dédié nécessaire
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
### Références
|
|
126
|
+
|
|
127
|
+
- [The Rust Book](https://doc.rust-lang.org/book)
|
|
128
|
+
- [Rustlings](https://github.com/rust-lang/rustlings)
|
|
129
|
+
- [tower-lsp](https://github.com/ebkalderon/tower-lsp)
|
|
130
|
+
- [LSP Specification — textDocument/formatting](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_formatting)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ssh-config-ls"
|
|
3
|
+
version = "0.7.4"
|
|
4
|
+
description = "An SSH config formatter, usable as a Language Server Protocol (LSP) plugin."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Nicolas MAIGNAN", email = "nicolas.maignan@proton.me" }]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"pygls>=2.1.1",
|
|
12
|
+
"typer>=0.26.7",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Documentation = "https://ssh-config-formatter-2adfa2.gitlab.io"
|
|
17
|
+
Repository = "https://gitlab.com/nifra/ssh-config-ls"
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
sshls = "ssh_config_ls.cli:app"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"pdoc>=16.0.0",
|
|
25
|
+
"pytest>=9.0.3",
|
|
26
|
+
"ruff>=0.15.14",
|
|
27
|
+
"ty>=0.0.40",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["uv_build>=0.11.15,<0.12.0"]
|
|
32
|
+
build-backend = "uv_build"
|
|
33
|
+
|
|
34
|
+
[tool.pytest]
|
|
35
|
+
addopts = ["--import-mode=importlib"]
|
|
36
|
+
pythonpath = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 88
|
|
40
|
+
indent-width = 4
|
|
41
|
+
|
|
42
|
+
[tool.ruff.lint]
|
|
43
|
+
select = ["ALL"]
|
|
44
|
+
ignore = [
|
|
45
|
+
"FIX", # Editors should highlight those, but dev notes mustn't block commit
|
|
46
|
+
"TD", # TODO/FIXME tracking overkill for solo project
|
|
47
|
+
"COM812", # Ruff formats trailing commas, so redundant
|
|
48
|
+
"PLC0415", # Imports inside functions needed until Python supports lazy imports natively
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[tool.ruff.lint.isort]
|
|
52
|
+
split-on-trailing-comma = false
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint.pydocstyle]
|
|
55
|
+
convention = "google"
|
|
56
|
+
|
|
57
|
+
[tool.ruff.format]
|
|
58
|
+
docstring-code-format = true
|
|
59
|
+
line-ending = "lf"
|
|
60
|
+
skip-magic-trailing-comma = true
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""An SSH config formatter, usable as a Language Server Protocol (LSP) plugin.
|
|
2
|
+
|
|
3
|
+
Modules
|
|
4
|
+
|
|
5
|
+
- `ssh_config_ls.cli` — CLI to use implemented functionalities.
|
|
6
|
+
- `ssh_config_ls.lsp` — language server declaration.
|
|
7
|
+
- `ssh_config_ls.lexer` — tokenization of SSH config files.
|
|
8
|
+
- `ssh_config_ls.parser` — parsing of SSH config tokens.
|
|
9
|
+
- `ssh_config_ls.formatter` — formatting of SSH config tree.
|
|
10
|
+
- `ssh_config_ls.exceptions` — package exceptions.
|
|
11
|
+
|
|
12
|
+
## What is ssh-config-ls?
|
|
13
|
+
|
|
14
|
+
This module is an ongoing prototype of a language server, dedicated to OpenSSH
|
|
15
|
+
configuration files. Its current planned scope is to be usable as a formatter
|
|
16
|
+
and potentially lint some errors (detected through the parsing of the file).
|
|
17
|
+
The prototype is developed in Python and once hitting a mature state, the Rust
|
|
18
|
+
implementation will start.
|
|
19
|
+
|
|
20
|
+
## Why developing a LS for SSH configurations?
|
|
21
|
+
|
|
22
|
+
This little side project is an excuse to, in order of importance:
|
|
23
|
+
|
|
24
|
+
- force me to start learning Rust;
|
|
25
|
+
- understand LSP;
|
|
26
|
+
- work my unit testing;
|
|
27
|
+
- revise my knowledge in parsing;
|
|
28
|
+
- create an application that I may need 3 minutes in my whole life and I didn't
|
|
29
|
+
find existing.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from importlib.metadata import version
|
|
33
|
+
|
|
34
|
+
__version__ = version(__name__)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""CLI of SSH-config-ls."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ssh_config_ls.formatter import DEFAULT_INDENT, DEFAULT_SEPARATOR
|
|
9
|
+
from ssh_config_ls.parser import DEFAULT_SORT
|
|
10
|
+
|
|
11
|
+
# ------------------------------------------------------------
|
|
12
|
+
# APPLICATION
|
|
13
|
+
# ------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
help="An SSH config formatter, usable as a Language Server Protocol (LSP) plugin.",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# ------------------------------------------------------------
|
|
21
|
+
# COMMANDS
|
|
22
|
+
# ------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command(name="format", no_args_is_help=True)
|
|
26
|
+
def cli_format_config(
|
|
27
|
+
file: Annotated[Path, typer.Argument(help="Path to the config file.")],
|
|
28
|
+
indent: Annotated[
|
|
29
|
+
str, typer.Option(help="Indentation applied to directives inside a block.")
|
|
30
|
+
] = DEFAULT_INDENT,
|
|
31
|
+
separator: Annotated[
|
|
32
|
+
str, typer.Option(help="Separator between keywords and their arguments.")
|
|
33
|
+
] = DEFAULT_SEPARATOR,
|
|
34
|
+
sort_directives: Annotated[
|
|
35
|
+
bool, typer.Option(help="Indentation applied to directives inside a block.")
|
|
36
|
+
] = DEFAULT_SORT,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Format an SSH config file."""
|
|
39
|
+
from ssh_config_ls.formatter import format_tree
|
|
40
|
+
from ssh_config_ls.lexer import tokenize
|
|
41
|
+
from ssh_config_ls.parser import parse
|
|
42
|
+
|
|
43
|
+
content = file.read_text()
|
|
44
|
+
|
|
45
|
+
tokens = tokenize(content)
|
|
46
|
+
syntax_tree = parse(tokens, sort_directives=sort_directives)
|
|
47
|
+
formatted_content = format_tree(syntax_tree, indent=indent, separator=separator)
|
|
48
|
+
|
|
49
|
+
with file.open("w") as f:
|
|
50
|
+
f.write(formatted_content)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command(name="server")
|
|
54
|
+
def cli_start_server() -> None:
|
|
55
|
+
"""Start the SSH config language server."""
|
|
56
|
+
from ssh_config_ls.lsp import server
|
|
57
|
+
|
|
58
|
+
server.start_io()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Exceptions raised by the SSH config LS."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SSHConfigError(ValueError):
|
|
5
|
+
"""Base class for SSH config parsing errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BadConfigurationOptionError(SSHConfigError):
|
|
9
|
+
"""The given keyword does not exist in the specification."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NoArgumentAfterKeywordError(SSHConfigError):
|
|
13
|
+
"""No argument has been given with the keyword."""
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Write a formatted SSH config from a syntax tree."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from functools import partial
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
from ssh_config_ls.lexer import Keyword, KeywordArgToken
|
|
9
|
+
from ssh_config_ls.parser import GlobalBlock, HostBlock, MatchBlock, SSHBlock, SSHConfig
|
|
10
|
+
|
|
11
|
+
# ------------------------------------------------------------
|
|
12
|
+
# CONSTANTS
|
|
13
|
+
# ------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
DEFAULT_INDENT: Final[str] = " "
|
|
16
|
+
"""Default indent for Host and Match blocks directives."""
|
|
17
|
+
DEFAULT_SEPARATOR: Final[str] = " "
|
|
18
|
+
"""Default separator between a keyword and its arguments."""
|
|
19
|
+
|
|
20
|
+
# ------------------------------------------------------------
|
|
21
|
+
# PUBLIC FUNCTIONS
|
|
22
|
+
# ------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_tree(
|
|
26
|
+
tree: SSHConfig,
|
|
27
|
+
/,
|
|
28
|
+
*,
|
|
29
|
+
indent: str = DEFAULT_INDENT,
|
|
30
|
+
separator: str = DEFAULT_SEPARATOR,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Format an SSH config syntax tree as a string.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
tree: The syntax tree of the SSH config.
|
|
36
|
+
indent: Indentation applied to directives inside a block.
|
|
37
|
+
separator: Separator between keywords and their arguments.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
The formatted SSH config as a string.
|
|
41
|
+
"""
|
|
42
|
+
if re.sub(r"\s*", "", indent) != "":
|
|
43
|
+
msg = f"Invalid indent {indent!r}: must contain only whitespace."
|
|
44
|
+
raise ValueError(msg)
|
|
45
|
+
|
|
46
|
+
if re.sub(r"\s*=\s*|\s+", "", separator) != "":
|
|
47
|
+
msg = f"Invalid separator {separator!r}: must be whitespace or '='."
|
|
48
|
+
raise ValueError(msg)
|
|
49
|
+
|
|
50
|
+
return "\n\n".join(
|
|
51
|
+
[
|
|
52
|
+
_format_block(block, indent=indent, separator=separator)
|
|
53
|
+
for block in tree.blocks
|
|
54
|
+
if len(block.directives) > 0
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------
|
|
60
|
+
# PRIVATE FUNCTIONS
|
|
61
|
+
# ------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _format_block(
|
|
65
|
+
block: SSHBlock,
|
|
66
|
+
/,
|
|
67
|
+
*,
|
|
68
|
+
indent: str = DEFAULT_INDENT,
|
|
69
|
+
separator: str = DEFAULT_SEPARATOR,
|
|
70
|
+
) -> str:
|
|
71
|
+
"""Format an SSH config syntax block as a string.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
block: A syntax block of the SSH config.
|
|
75
|
+
indent: Indentation applied to directives inside the block.
|
|
76
|
+
separator: Separator between keywords and their arguments.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The formatted SSH config block as a string.
|
|
80
|
+
"""
|
|
81
|
+
indent_used = "" if isinstance(block, GlobalBlock) else indent
|
|
82
|
+
formatted_lines: list[str] = []
|
|
83
|
+
if isinstance(block, HostBlock):
|
|
84
|
+
formatted_lines.append(
|
|
85
|
+
_write_keyword_argument_line(
|
|
86
|
+
Keyword.Host, block.patterns, block.comment, indent=""
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
elif isinstance(block, MatchBlock):
|
|
90
|
+
formatted_lines.append(
|
|
91
|
+
_write_keyword_argument_line(
|
|
92
|
+
Keyword.Match, (block.conditions), block.comment, indent=""
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
for line in block.directives:
|
|
96
|
+
if isinstance(line, KeywordArgToken):
|
|
97
|
+
formatted_lines.append(
|
|
98
|
+
_write_keyword_argument_line(
|
|
99
|
+
line.keyword,
|
|
100
|
+
line.args,
|
|
101
|
+
line.comment,
|
|
102
|
+
indent=indent_used,
|
|
103
|
+
separator=separator,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
formatted_lines.append(f"{indent_used}{line}")
|
|
108
|
+
return "\n".join(formatted_lines)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _write_keyword_argument_line(
|
|
112
|
+
keyword: Keyword,
|
|
113
|
+
args: Sequence[str],
|
|
114
|
+
comment: str | None,
|
|
115
|
+
/,
|
|
116
|
+
*,
|
|
117
|
+
indent: str = DEFAULT_INDENT,
|
|
118
|
+
separator: str = DEFAULT_SEPARATOR,
|
|
119
|
+
) -> str:
|
|
120
|
+
"""Write a keyword-argument line of an SSH config.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
keyword: The keyword of the directive.
|
|
124
|
+
args: Arguments associated with the keyword.
|
|
125
|
+
comment: Inline comment to append, if any.
|
|
126
|
+
indent: Indentation prefix applied to the line.
|
|
127
|
+
separator: Separator between the keyword and its arguments.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The formatted line as a string.
|
|
131
|
+
"""
|
|
132
|
+
line = (
|
|
133
|
+
f"{indent}{keyword.name}{separator}"
|
|
134
|
+
f"{' '.join(map(partial(_quote_arg_if_needed, keyword), args))}"
|
|
135
|
+
)
|
|
136
|
+
if comment:
|
|
137
|
+
line += f" {comment}"
|
|
138
|
+
return line
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _quote_arg_if_needed(keyword: Keyword, arg: str) -> str:
|
|
142
|
+
"""Quote an argument if it contains whitespace.
|
|
143
|
+
|
|
144
|
+
Arguments for LocalCommand, ProxyCommand, and RemoteCommand are never
|
|
145
|
+
quoted as they are shell commands interpreted verbatim by OpenSSH.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
keyword: Keyword the argument belongs to.
|
|
149
|
+
arg: Argument to potentially quote.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
The argument wrapped in double quotes if it contains whitespace,
|
|
153
|
+
unchanged otherwise.
|
|
154
|
+
"""
|
|
155
|
+
if (
|
|
156
|
+
keyword
|
|
157
|
+
not in (Keyword.LocalCommand, Keyword.ProxyCommand, Keyword.RemoteCommand)
|
|
158
|
+
and " " in arg
|
|
159
|
+
):
|
|
160
|
+
return f'"{arg}"'
|
|
161
|
+
return arg
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Read an SSH config content to extract tokens."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import StrEnum, auto
|
|
6
|
+
|
|
7
|
+
from ssh_config_ls.exceptions import (
|
|
8
|
+
BadConfigurationOptionError,
|
|
9
|
+
NoArgumentAfterKeywordError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
# ------------------------------------------------------------
|
|
13
|
+
# CLASSES
|
|
14
|
+
# ------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Keyword(StrEnum):
|
|
18
|
+
"""Keyword tokens used in SSH configs."""
|
|
19
|
+
|
|
20
|
+
Host = auto()
|
|
21
|
+
Match = auto()
|
|
22
|
+
AddKeysToAgent = auto()
|
|
23
|
+
AddressFamily = auto()
|
|
24
|
+
BatchMode = auto()
|
|
25
|
+
BindAddress = auto()
|
|
26
|
+
BindInterface = auto()
|
|
27
|
+
CanonicalDomains = auto()
|
|
28
|
+
CanonicalizeFallbackLocal = auto()
|
|
29
|
+
CanonicalizeHostname = auto()
|
|
30
|
+
CanonicalizeMaxDots = auto()
|
|
31
|
+
CanonicalizePermittedCNAMEs = auto()
|
|
32
|
+
CASignatureAlgorithms = auto()
|
|
33
|
+
CertificateFile = auto()
|
|
34
|
+
ChannelTimeout = auto()
|
|
35
|
+
CheckHostIP = auto()
|
|
36
|
+
Ciphers = auto()
|
|
37
|
+
ClearAllForwardings = auto()
|
|
38
|
+
Compression = auto()
|
|
39
|
+
ConnectionAttempts = auto()
|
|
40
|
+
ConnectTimeout = auto()
|
|
41
|
+
ControlMaster = auto()
|
|
42
|
+
ControlPath = auto()
|
|
43
|
+
ControlPersist = auto()
|
|
44
|
+
DynamicForward = auto()
|
|
45
|
+
EnableEscapeCommandline = auto()
|
|
46
|
+
EnableSSHKeysign = auto()
|
|
47
|
+
EscapeChar = auto()
|
|
48
|
+
ExitOnForwardFailure = auto()
|
|
49
|
+
FingerprintHash = auto()
|
|
50
|
+
ForkAfterAuthentification = auto()
|
|
51
|
+
ForwardAgent = auto()
|
|
52
|
+
ForwardX11 = auto()
|
|
53
|
+
ForwardX11Timeout = auto()
|
|
54
|
+
ForwardX11Trusted = auto()
|
|
55
|
+
GatewayPorts = auto()
|
|
56
|
+
GlobalKnownHostsFile = auto()
|
|
57
|
+
GSSAPIAuthentification = auto()
|
|
58
|
+
GSSAPIDelegateCredentials = auto()
|
|
59
|
+
HashKnownHosts = auto()
|
|
60
|
+
HostbasedAcceptedAlgorithms = auto()
|
|
61
|
+
HostbasedAuthentification = auto()
|
|
62
|
+
HostKeyAlgorithms = auto()
|
|
63
|
+
HostKeyAlias = auto()
|
|
64
|
+
Hostname = auto()
|
|
65
|
+
IdentitiesOnly = auto()
|
|
66
|
+
IdentityAgent = auto()
|
|
67
|
+
IdentityFile = auto()
|
|
68
|
+
IgnoreUnknown = auto()
|
|
69
|
+
Include = auto()
|
|
70
|
+
IPQoS = auto()
|
|
71
|
+
KbdInteractiveAuthentication = auto()
|
|
72
|
+
KbdInteractiveDevices = auto()
|
|
73
|
+
KexAlgorithms = auto()
|
|
74
|
+
KnownHostsCommand = auto()
|
|
75
|
+
LocalCommand = auto()
|
|
76
|
+
LocalForward = auto()
|
|
77
|
+
LogLevel = auto()
|
|
78
|
+
LogVerbose = auto()
|
|
79
|
+
MACs = auto()
|
|
80
|
+
NoHostAuthenticationForLocalhost = auto()
|
|
81
|
+
NumberOfPasswordPrompts = auto()
|
|
82
|
+
ObscureKeystrokeTiming = auto()
|
|
83
|
+
PasswordAuthentication = auto()
|
|
84
|
+
PermitLocalCommand = auto()
|
|
85
|
+
PermitRemoteOpen = auto()
|
|
86
|
+
PKCS11Provider = auto()
|
|
87
|
+
Port = auto()
|
|
88
|
+
PreferredAuthentications = auto()
|
|
89
|
+
ProxyCommand = auto()
|
|
90
|
+
ProxyJump = auto()
|
|
91
|
+
ProxyUseFdpass = auto()
|
|
92
|
+
PubkeyAcceptedAlgorithms = auto()
|
|
93
|
+
PubkeyAuthentication = auto()
|
|
94
|
+
RefuseConnection = auto()
|
|
95
|
+
RekeyLimit = auto()
|
|
96
|
+
RemoteCommand = auto()
|
|
97
|
+
RemoteForward = auto()
|
|
98
|
+
RequestTTY = auto()
|
|
99
|
+
RequiredRSASize = auto()
|
|
100
|
+
RevokedHostKeys = auto()
|
|
101
|
+
SecurityKeyProvider = auto()
|
|
102
|
+
SendEnv = auto()
|
|
103
|
+
ServerAliveCountMax = auto()
|
|
104
|
+
ServerAliveInterval = auto()
|
|
105
|
+
SessionType = auto()
|
|
106
|
+
SetEnv = auto()
|
|
107
|
+
StdinNull = auto()
|
|
108
|
+
StreamLocalBindMask = auto()
|
|
109
|
+
StreamLocalBindUnlink = auto()
|
|
110
|
+
StrictHostKeyChecking = auto()
|
|
111
|
+
SyslogFacility = auto()
|
|
112
|
+
TCKeepAlive = auto()
|
|
113
|
+
Tag = auto()
|
|
114
|
+
Tunnel = auto()
|
|
115
|
+
TunnelDevice = auto()
|
|
116
|
+
UpdateHostKeys = auto()
|
|
117
|
+
User = auto()
|
|
118
|
+
UserKnownHostsFile = auto()
|
|
119
|
+
VerifyHostKeyDNS = auto()
|
|
120
|
+
VersionAddendum = auto()
|
|
121
|
+
VisualHostKey = auto()
|
|
122
|
+
WarnWeakCrypto = auto()
|
|
123
|
+
XAuthLocation = auto()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class KeywordArgToken:
|
|
128
|
+
"""Token of a keyword-argument line."""
|
|
129
|
+
|
|
130
|
+
keyword: Keyword
|
|
131
|
+
"""Token representing the keyword of the line."""
|
|
132
|
+
args: list[str]
|
|
133
|
+
"""Token representing the arguments of the line."""
|
|
134
|
+
comment: str | None = None
|
|
135
|
+
"""Token representing inline comment if presents."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
LineToken = KeywordArgToken | str
|
|
139
|
+
"""Token of a non-empty line in SSH config."""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------
|
|
143
|
+
# PUBLIC FUNCTIONS
|
|
144
|
+
# ------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def tokenize(content: str) -> list[LineToken]:
|
|
148
|
+
"""Read a SSH config content to extract tokens.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
content: The content of an SSH config file.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
All keyword-argument lines from the content read as tokens.
|
|
155
|
+
"""
|
|
156
|
+
token_list = []
|
|
157
|
+
for line in content.splitlines():
|
|
158
|
+
if line.strip() == "":
|
|
159
|
+
continue
|
|
160
|
+
if _is_standalone_comment(line):
|
|
161
|
+
token_list.append(line.strip())
|
|
162
|
+
else:
|
|
163
|
+
token_list.append(_split_line(line))
|
|
164
|
+
return token_list
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------
|
|
168
|
+
# PRIVATE FUNCTIONS
|
|
169
|
+
# ------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _is_standalone_comment(line: str) -> bool:
|
|
173
|
+
"""Check if the line is a comment.
|
|
174
|
+
|
|
175
|
+
As explained in the [`ssh_config`](https://man.openbsd.org/ssh_config) manual:
|
|
176
|
+
Lines starting with # and empty lines are interpreted as comments.
|
|
177
|
+
|
|
178
|
+
We choose to consider that only lines with # as first non-whitespace character
|
|
179
|
+
as a standalone comment and so tokenized.
|
|
180
|
+
"""
|
|
181
|
+
return line.lstrip()[0] == "#"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _split_line(line: str) -> KeywordArgToken:
|
|
185
|
+
"""Split a line as a keyword-argument pair.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
line: A keyword-argument line from an SSH config.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
The line as a `Keyword`-argument token.
|
|
192
|
+
"""
|
|
193
|
+
words = re.split(r"\s*=\s*|\s+", line.lstrip(), maxsplit=1)
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
keyword = Keyword(words[0].lower())
|
|
197
|
+
except ValueError as e:
|
|
198
|
+
raise BadConfigurationOptionError from e
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
arg_line = words[1]
|
|
202
|
+
except IndexError as e:
|
|
203
|
+
raise NoArgumentAfterKeywordError from e
|
|
204
|
+
|
|
205
|
+
match keyword:
|
|
206
|
+
case Keyword.LocalCommand | Keyword.ProxyCommand | Keyword.RemoteCommand:
|
|
207
|
+
args = [arg_line.strip()]
|
|
208
|
+
inline_comment = None
|
|
209
|
+
case _:
|
|
210
|
+
args, inline_comment = _split_argument(arg_line)
|
|
211
|
+
|
|
212
|
+
if len(args) == 0:
|
|
213
|
+
raise NoArgumentAfterKeywordError
|
|
214
|
+
|
|
215
|
+
return KeywordArgToken(keyword, args, inline_comment)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _split_argument(rest_of_line: str) -> tuple[list[str], str | None]:
|
|
219
|
+
"""Split an argument from an SSH config line.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
rest_of_line: The argument part of an SSH config line.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
Arguments split by whitespace, except when quoted.
|
|
226
|
+
"""
|
|
227
|
+
args = []
|
|
228
|
+
argument = ""
|
|
229
|
+
inline_comment = None
|
|
230
|
+
in_quote = False
|
|
231
|
+
for i, ch in enumerate(rest_of_line):
|
|
232
|
+
if ch == "#" and not in_quote:
|
|
233
|
+
inline_comment = rest_of_line[i:].strip()
|
|
234
|
+
break
|
|
235
|
+
if ch == " " and not in_quote:
|
|
236
|
+
argument = argument.strip().strip('"')
|
|
237
|
+
if argument != "":
|
|
238
|
+
args.append(argument)
|
|
239
|
+
argument = ""
|
|
240
|
+
continue
|
|
241
|
+
if ch == '"':
|
|
242
|
+
in_quote = not in_quote
|
|
243
|
+
argument += ch
|
|
244
|
+
|
|
245
|
+
argument = argument.strip().strip('"')
|
|
246
|
+
if argument != "":
|
|
247
|
+
args.append(argument)
|
|
248
|
+
|
|
249
|
+
return args, inline_comment
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Describe the Language Server."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
from lsprotocol.types import (
|
|
6
|
+
TEXT_DOCUMENT_FORMATTING,
|
|
7
|
+
ConfigurationItem,
|
|
8
|
+
ConfigurationParams,
|
|
9
|
+
DocumentFormattingParams,
|
|
10
|
+
Position,
|
|
11
|
+
Range,
|
|
12
|
+
TextEdit,
|
|
13
|
+
)
|
|
14
|
+
from pygls.lsp.server import LanguageServer
|
|
15
|
+
|
|
16
|
+
from ssh_config_ls.formatter import DEFAULT_INDENT, DEFAULT_SEPARATOR, format_tree
|
|
17
|
+
from ssh_config_ls.lexer import tokenize
|
|
18
|
+
from ssh_config_ls.parser import DEFAULT_SORT, parse
|
|
19
|
+
|
|
20
|
+
# ------------------------------------------------------------
|
|
21
|
+
# SERVER
|
|
22
|
+
# ------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
server = LanguageServer("ssh-config-ls", version("ssh_config_ls"))
|
|
25
|
+
"""SSH config language server."""
|
|
26
|
+
|
|
27
|
+
# ------------------------------------------------------------
|
|
28
|
+
# CAPABILITIES
|
|
29
|
+
# ------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@server.feature(TEXT_DOCUMENT_FORMATTING)
|
|
33
|
+
async def format_document(
|
|
34
|
+
ls: LanguageServer, params: DocumentFormattingParams
|
|
35
|
+
) -> list[TextEdit]:
|
|
36
|
+
"""Format the whole document."""
|
|
37
|
+
config = await ls.workspace_configuration_async(
|
|
38
|
+
ConfigurationParams(items=[ConfigurationItem(section="formatter")])
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
formatter_config = config[0] or {}
|
|
42
|
+
indent = formatter_config.get("indent", DEFAULT_INDENT)
|
|
43
|
+
separator = formatter_config.get("separator", DEFAULT_SEPARATOR)
|
|
44
|
+
sort_directives = formatter_config.get("sort_directives", DEFAULT_SORT)
|
|
45
|
+
|
|
46
|
+
doc = ls.workspace.get_text_document(params.text_document.uri)
|
|
47
|
+
text = doc.source
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
formatted = format_tree(
|
|
51
|
+
parse(tokenize(text), sort_directives=sort_directives),
|
|
52
|
+
indent=indent,
|
|
53
|
+
separator=separator,
|
|
54
|
+
)
|
|
55
|
+
except ValueError:
|
|
56
|
+
return []
|
|
57
|
+
|
|
58
|
+
lines = text.splitlines()
|
|
59
|
+
if lines:
|
|
60
|
+
last_line = len(lines) - 1
|
|
61
|
+
last_char = len(lines[last_line])
|
|
62
|
+
else:
|
|
63
|
+
last_line, last_char = 0, 0
|
|
64
|
+
|
|
65
|
+
return [
|
|
66
|
+
TextEdit(
|
|
67
|
+
range=Range(
|
|
68
|
+
start=Position(line=0, character=0),
|
|
69
|
+
end=Position(line=last_line, character=last_char),
|
|
70
|
+
),
|
|
71
|
+
new_text=formatted,
|
|
72
|
+
)
|
|
73
|
+
]
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Construct a syntax tree from the tokens of an SSH config."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from ssh_config_ls.lexer import Keyword, KeywordArgToken, LineToken
|
|
8
|
+
|
|
9
|
+
# ------------------------------------------------------------
|
|
10
|
+
# CONSTANTS
|
|
11
|
+
# ------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
DEFAULT_SORT: Final[bool] = True
|
|
14
|
+
"""Sort blocks directives by default."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ------------------------------------------------------------
|
|
18
|
+
# CLASSES
|
|
19
|
+
# ------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
Directive = KeywordArgToken | str
|
|
23
|
+
"""A directive in an SSH config block, or a standalone comment."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class GlobalBlock:
|
|
28
|
+
"""Block applied as a fallback to every destination."""
|
|
29
|
+
|
|
30
|
+
directives: tuple[Directive, ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class HostBlock:
|
|
35
|
+
"""Block applied to hosts matching the given patterns."""
|
|
36
|
+
|
|
37
|
+
patterns: tuple[str, ...]
|
|
38
|
+
directives: tuple[Directive, ...]
|
|
39
|
+
comment: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class MatchBlock:
|
|
44
|
+
"""Block applied when the given conditions are met."""
|
|
45
|
+
|
|
46
|
+
conditions: str
|
|
47
|
+
directives: tuple[Directive, ...]
|
|
48
|
+
comment: str | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
SSHBlock = GlobalBlock | HostBlock | MatchBlock
|
|
52
|
+
"""Block in a SSH config."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class SSHConfig:
|
|
57
|
+
"""Syntax tree representing an SSH configuration."""
|
|
58
|
+
|
|
59
|
+
blocks: tuple[SSHBlock, ...] = (GlobalBlock(()),)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------
|
|
63
|
+
# PUBLIC FUNCTIONS
|
|
64
|
+
# ------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse(
|
|
68
|
+
tokens: Sequence[LineToken], /, *, sort_directives: bool = DEFAULT_SORT
|
|
69
|
+
) -> SSHConfig:
|
|
70
|
+
"""Construct a syntax tree from the tokens of an SSH config.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
tokens: Tokens representing lines of an SSH config.
|
|
74
|
+
sort_directives: Sort directives within parsed blocks.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The syntax tree of the SSH config.
|
|
78
|
+
"""
|
|
79
|
+
blocks = []
|
|
80
|
+
current_block: KeywordArgToken | None = None
|
|
81
|
+
current_directives: list[LineToken] = []
|
|
82
|
+
|
|
83
|
+
for tok in tokens:
|
|
84
|
+
if isinstance(tok, str):
|
|
85
|
+
current_directives.append(tok)
|
|
86
|
+
elif tok.keyword in (Keyword.Host, Keyword.Match):
|
|
87
|
+
blocks.append(
|
|
88
|
+
_flush_block(
|
|
89
|
+
current_block, current_directives, sort_directives=sort_directives
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
current_directives = []
|
|
93
|
+
current_block = tok
|
|
94
|
+
else:
|
|
95
|
+
current_directives.append(tok)
|
|
96
|
+
blocks.append(
|
|
97
|
+
_flush_block(current_block, current_directives, sort_directives=sort_directives)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return SSHConfig(tuple(blocks))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------
|
|
104
|
+
# PRIVATE FUNCTIONS
|
|
105
|
+
# ------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _flush_block(
|
|
109
|
+
block_definition: KeywordArgToken | None,
|
|
110
|
+
directives: Sequence[LineToken],
|
|
111
|
+
/,
|
|
112
|
+
*,
|
|
113
|
+
sort_directives: bool = True,
|
|
114
|
+
) -> SSHBlock:
|
|
115
|
+
"""Construct a syntax block from an SSH config block.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
block_definition: Host or Match keyword-argument delimiting the block.
|
|
119
|
+
None if it is the global block of the config.
|
|
120
|
+
directives: Keyword-argument tokens and standalone comments
|
|
121
|
+
belonging to the block.
|
|
122
|
+
sort_directives: Sort directives in the block.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The syntax block representing the structure of the config block.
|
|
126
|
+
"""
|
|
127
|
+
sort = _sort_directives if sort_directives else tuple
|
|
128
|
+
|
|
129
|
+
if block_definition is None:
|
|
130
|
+
return GlobalBlock(sort(directives))
|
|
131
|
+
|
|
132
|
+
if block_definition.keyword == Keyword.Host:
|
|
133
|
+
return HostBlock(
|
|
134
|
+
tuple(block_definition.args),
|
|
135
|
+
sort(directives),
|
|
136
|
+
comment=block_definition.comment,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if block_definition.keyword == Keyword.Match:
|
|
140
|
+
return MatchBlock(
|
|
141
|
+
" ".join(block_definition.args),
|
|
142
|
+
sort(directives),
|
|
143
|
+
comment=block_definition.comment,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
msg = f"Expected Host or Match keyword, got {block_definition.keyword}"
|
|
147
|
+
raise ValueError(msg)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _sort_directives(directives: Sequence[Directive]) -> tuple[Directive, ...]:
|
|
151
|
+
"""Sort directives by keyword order if no standalone comment is present.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
directives: Sequence of directive tokens (keyword-argument lines or
|
|
155
|
+
standalone comments) from a parsed block.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
The directives sorted by keyword declaration order if no standalone
|
|
159
|
+
comment is present, otherwise the original sequence as a tuple.
|
|
160
|
+
"""
|
|
161
|
+
if not any(isinstance(line, str) for line in directives):
|
|
162
|
+
keyword_order = list(Keyword)
|
|
163
|
+
return tuple(sorted(directives, key=lambda k: keyword_order.index(k.keyword)))
|
|
164
|
+
return tuple(directives)
|
|
File without changes
|