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.
@@ -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
+ [![Python badge](https://img.shields.io/badge/Python-3.11+-0066cc?style=for-the-badge&logo=python&logoColor=yellow)](https://www.python.org/downloads/)
21
+ [![MIT License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)](https://spdx.org/licenses/MIT.html)
22
+
23
+ [![Packager: uv](https://gitlab.com/nifra/assets/-/raw/main/badges/uv.svg)](https://docs.astral.sh/uv/)
24
+ [![Linter/Formatter: ruff](https://gitlab.com/nifra/assets/-/raw/main/badges/ruff.svg)](https://docs.astral.sh/ruff/)
25
+ [![Type checker: ty](https://gitlab.com/nifra/assets/-/raw/main/badges/ty.svg)](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
+ [![Python badge](https://img.shields.io/badge/Python-3.11+-0066cc?style=for-the-badge&logo=python&logoColor=yellow)](https://www.python.org/downloads/)
6
+ [![MIT License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)](https://spdx.org/licenses/MIT.html)
7
+
8
+ [![Packager: uv](https://gitlab.com/nifra/assets/-/raw/main/badges/uv.svg)](https://docs.astral.sh/uv/)
9
+ [![Linter/Formatter: ruff](https://gitlab.com/nifra/assets/-/raw/main/badges/ruff.svg)](https://docs.astral.sh/ruff/)
10
+ [![Type checker: ty](https://gitlab.com/nifra/assets/-/raw/main/badges/ty.svg)](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