nvim-doc-reader 0.1.0__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.
- nvim_doc_reader-0.1.0/PKG-INFO +125 -0
- nvim_doc_reader-0.1.0/README.md +114 -0
- nvim_doc_reader-0.1.0/pyproject.toml +28 -0
- nvim_doc_reader-0.1.0/setup.cfg +4 -0
- nvim_doc_reader-0.1.0/src/doc_reader/__init__.py +1 -0
- nvim_doc_reader-0.1.0/src/doc_reader/cli.py +109 -0
- nvim_doc_reader-0.1.0/src/doc_reader/config.py +124 -0
- nvim_doc_reader-0.1.0/src/doc_reader/model.py +281 -0
- nvim_doc_reader-0.1.0/src/doc_reader/nvim/lua/doc_reader.lua +43 -0
- nvim_doc_reader-0.1.0/src/doc_reader/nvim.py +215 -0
- nvim_doc_reader-0.1.0/src/doc_reader/serve.py +235 -0
- nvim_doc_reader-0.1.0/src/doc_reader/skills/doc-authoring/SKILL.md +64 -0
- nvim_doc_reader-0.1.0/src/doc_reader/static/reader.css +167 -0
- nvim_doc_reader-0.1.0/src/doc_reader/static/reader.js +485 -0
- nvim_doc_reader-0.1.0/src/doc_reader/urls.py +87 -0
- nvim_doc_reader-0.1.0/src/nvim_doc_reader.egg-info/PKG-INFO +125 -0
- nvim_doc_reader-0.1.0/src/nvim_doc_reader.egg-info/SOURCES.txt +24 -0
- nvim_doc_reader-0.1.0/src/nvim_doc_reader.egg-info/dependency_links.txt +1 -0
- nvim_doc_reader-0.1.0/src/nvim_doc_reader.egg-info/entry_points.txt +3 -0
- nvim_doc_reader-0.1.0/src/nvim_doc_reader.egg-info/requires.txt +6 -0
- nvim_doc_reader-0.1.0/src/nvim_doc_reader.egg-info/top_level.txt +1 -0
- nvim_doc_reader-0.1.0/tests/test_model.py +101 -0
- nvim_doc_reader-0.1.0/tests/test_nvim.py +179 -0
- nvim_doc_reader-0.1.0/tests/test_serve.py +72 -0
- nvim_doc_reader-0.1.0/tests/test_skill.py +23 -0
- nvim_doc_reader-0.1.0/tests/test_urls_config.py +89 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nvim-doc-reader
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Keyboard-driven HTML reader for Markdown docs, with jumps into running Neovim
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: markdown-it-py<3,>=2.1
|
|
8
|
+
Requires-Dist: mdit-py-plugins<0.4,>=0.3.4
|
|
9
|
+
Requires-Dist: aiohttp>=3.8
|
|
10
|
+
Requires-Dist: tomli>=2.0; python_version < "3.11"
|
|
11
|
+
|
|
12
|
+
# doc-reader
|
|
13
|
+
|
|
14
|
+
A keyboard-driven HTML reader for Markdown documents, with jumps into the
|
|
15
|
+
Neovim instances you are editing them in. Markdown stays the only source: the
|
|
16
|
+
server renders it on every request, and the page follows edits live.
|
|
17
|
+
|
|
18
|
+
- Nested, foldable sections (`zM` to an outline, `l` to drill in), Vim keys.
|
|
19
|
+
- Math (KaTeX), Mermaid diagrams, SVG/PNG figures, tables.
|
|
20
|
+
- `n` moves every running Neovim that has the file to the section you are
|
|
21
|
+
reading; `nvimd` opens it when none has.
|
|
22
|
+
- `q` copies a question prompt for a coding agent; `y` copies a link.
|
|
23
|
+
- `<leader>h` in Neovim prints the reader URL of the section under the cursor.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install nvim-doc-reader
|
|
29
|
+
docread serve
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires Python 3.9+ and, for the editor features, Neovim 0.9+.
|
|
33
|
+
|
|
34
|
+
## Configure
|
|
35
|
+
|
|
36
|
+
`$XDG_CONFIG_HOME/doc-reader/config.toml` (or `$DOCREADER_CONFIG`):
|
|
37
|
+
|
|
38
|
+
```toml
|
|
39
|
+
# Optional, with their defaults.
|
|
40
|
+
bind = "0.0.0.0"
|
|
41
|
+
port = 8765
|
|
42
|
+
public_url = "http://desknikos:8765" # what `docread url` prints; default http://localhost:<port>
|
|
43
|
+
nvim = "nvim"
|
|
44
|
+
# nvim_socket_glob: default is where Neovim puts its own socket
|
|
45
|
+
# ($XDG_RUNTIME_DIR/nvim.*.0, else $TMPDIR/nvim.<user>/*/nvim.*.0)
|
|
46
|
+
state_file = "$XDG_STATE_HOME/doc-reader/last"
|
|
47
|
+
poll_interval_s = 0.5
|
|
48
|
+
|
|
49
|
+
# Which directories are served, by name. The name is part of the URL:
|
|
50
|
+
# /efs/nikos/notes/a.md -> /v/nikos/notes/a.md
|
|
51
|
+
# Default: the home directory, as "home".
|
|
52
|
+
[roots]
|
|
53
|
+
nikos = "/efs/nikos"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Every scalar key can be overridden with `DOCREADER_<KEY>`, and the roots with
|
|
57
|
+
`DOCREADER_ROOTS=name=/dir:name=/dir`. Nested roots are fine: a file belongs to
|
|
58
|
+
the most specific one, and any other URL for it redirects there.
|
|
59
|
+
|
|
60
|
+
Only files under a root are served. Raw files are served with
|
|
61
|
+
`Content-Security-Policy: sandbox`, and raw HTML in Markdown is shown as text.
|
|
62
|
+
There is no authentication: run it on a trusted network.
|
|
63
|
+
|
|
64
|
+
## Keys
|
|
65
|
+
|
|
66
|
+
| key | action |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `j` / `k` | next / previous heading |
|
|
69
|
+
| `h` / `l` | collapse or go to parent / expand or go to first child |
|
|
70
|
+
| `za` `zo` `zc` `zO` `zC` | toggle / open / close / open all inside / close all inside |
|
|
71
|
+
| `zM` / `zR` | close all / open all |
|
|
72
|
+
| `gg` / `G`, `Ctrl-d` / `Ctrl-u` | top / bottom, half page |
|
|
73
|
+
| `Tab`, `Enter` | focus and follow links |
|
|
74
|
+
| `n` | open in Neovim |
|
|
75
|
+
| `q` | copy a question prompt |
|
|
76
|
+
| `y` | copy the section's link |
|
|
77
|
+
| `o` | show / hide the outline |
|
|
78
|
+
| `?` | help |
|
|
79
|
+
|
|
80
|
+
## Neovim
|
|
81
|
+
|
|
82
|
+
`n` needs nothing in the editor: every Neovim listens on its own socket, and
|
|
83
|
+
the server finds them by `nvim_socket_glob`. Windows showing the file move to
|
|
84
|
+
the section; a buffer that is loaded but hidden moves when you next switch to
|
|
85
|
+
it. `nvimd` with no arguments opens the last target; with arguments it is
|
|
86
|
+
plain `nvim`.
|
|
87
|
+
|
|
88
|
+
For `<leader>h`:
|
|
89
|
+
|
|
90
|
+
```lua
|
|
91
|
+
vim.opt.rtp:append(vim.fn.trim(vim.fn.system({ "docread", "nvim-path" })))
|
|
92
|
+
require("doc_reader").setup({ cmd = { "docread" }, keymap = "<leader>h" })
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Section IDs
|
|
96
|
+
|
|
97
|
+
A heading's ID is its explicit `{#id}` if it has one, otherwise GitHub's slug
|
|
98
|
+
of its text, with `-1`, `-2` for repeats. Give a section an explicit ID when
|
|
99
|
+
links to it must survive a rename.
|
|
100
|
+
|
|
101
|
+
## Agents
|
|
102
|
+
|
|
103
|
+
Documents written by coding agents read best when they follow a few rules:
|
|
104
|
+
stable `{#id}`s, short overviews with depth in nested subsections, figures
|
|
105
|
+
kept with their source, no fabricated data, and questions answered without
|
|
106
|
+
editing. The rules ship as a Claude Code skill:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
docread install-skill # -> ~/.claude/skills/doc-authoring/
|
|
110
|
+
docread install-skill --dir .claude/skills # or per project
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Then tell agents to use it, in your global `~/.claude/CLAUDE.md` (or a
|
|
114
|
+
project's `CLAUDE.md`):
|
|
115
|
+
|
|
116
|
+
```markdown
|
|
117
|
+
## Writing Documents
|
|
118
|
+
|
|
119
|
+
Markdown documents you write for the user (research notes, reports, plans,
|
|
120
|
+
explainers) follow the `doc-authoring` skill — load it before creating or
|
|
121
|
+
editing one.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Other agents: if they read `SKILL.md` skills, point `--dir` at their skills
|
|
125
|
+
directory; otherwise paste the skill's body into `AGENTS.md` / `GEMINI.md`.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# doc-reader
|
|
2
|
+
|
|
3
|
+
A keyboard-driven HTML reader for Markdown documents, with jumps into the
|
|
4
|
+
Neovim instances you are editing them in. Markdown stays the only source: the
|
|
5
|
+
server renders it on every request, and the page follows edits live.
|
|
6
|
+
|
|
7
|
+
- Nested, foldable sections (`zM` to an outline, `l` to drill in), Vim keys.
|
|
8
|
+
- Math (KaTeX), Mermaid diagrams, SVG/PNG figures, tables.
|
|
9
|
+
- `n` moves every running Neovim that has the file to the section you are
|
|
10
|
+
reading; `nvimd` opens it when none has.
|
|
11
|
+
- `q` copies a question prompt for a coding agent; `y` copies a link.
|
|
12
|
+
- `<leader>h` in Neovim prints the reader URL of the section under the cursor.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install nvim-doc-reader
|
|
18
|
+
docread serve
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Python 3.9+ and, for the editor features, Neovim 0.9+.
|
|
22
|
+
|
|
23
|
+
## Configure
|
|
24
|
+
|
|
25
|
+
`$XDG_CONFIG_HOME/doc-reader/config.toml` (or `$DOCREADER_CONFIG`):
|
|
26
|
+
|
|
27
|
+
```toml
|
|
28
|
+
# Optional, with their defaults.
|
|
29
|
+
bind = "0.0.0.0"
|
|
30
|
+
port = 8765
|
|
31
|
+
public_url = "http://desknikos:8765" # what `docread url` prints; default http://localhost:<port>
|
|
32
|
+
nvim = "nvim"
|
|
33
|
+
# nvim_socket_glob: default is where Neovim puts its own socket
|
|
34
|
+
# ($XDG_RUNTIME_DIR/nvim.*.0, else $TMPDIR/nvim.<user>/*/nvim.*.0)
|
|
35
|
+
state_file = "$XDG_STATE_HOME/doc-reader/last"
|
|
36
|
+
poll_interval_s = 0.5
|
|
37
|
+
|
|
38
|
+
# Which directories are served, by name. The name is part of the URL:
|
|
39
|
+
# /efs/nikos/notes/a.md -> /v/nikos/notes/a.md
|
|
40
|
+
# Default: the home directory, as "home".
|
|
41
|
+
[roots]
|
|
42
|
+
nikos = "/efs/nikos"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Every scalar key can be overridden with `DOCREADER_<KEY>`, and the roots with
|
|
46
|
+
`DOCREADER_ROOTS=name=/dir:name=/dir`. Nested roots are fine: a file belongs to
|
|
47
|
+
the most specific one, and any other URL for it redirects there.
|
|
48
|
+
|
|
49
|
+
Only files under a root are served. Raw files are served with
|
|
50
|
+
`Content-Security-Policy: sandbox`, and raw HTML in Markdown is shown as text.
|
|
51
|
+
There is no authentication: run it on a trusted network.
|
|
52
|
+
|
|
53
|
+
## Keys
|
|
54
|
+
|
|
55
|
+
| key | action |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `j` / `k` | next / previous heading |
|
|
58
|
+
| `h` / `l` | collapse or go to parent / expand or go to first child |
|
|
59
|
+
| `za` `zo` `zc` `zO` `zC` | toggle / open / close / open all inside / close all inside |
|
|
60
|
+
| `zM` / `zR` | close all / open all |
|
|
61
|
+
| `gg` / `G`, `Ctrl-d` / `Ctrl-u` | top / bottom, half page |
|
|
62
|
+
| `Tab`, `Enter` | focus and follow links |
|
|
63
|
+
| `n` | open in Neovim |
|
|
64
|
+
| `q` | copy a question prompt |
|
|
65
|
+
| `y` | copy the section's link |
|
|
66
|
+
| `o` | show / hide the outline |
|
|
67
|
+
| `?` | help |
|
|
68
|
+
|
|
69
|
+
## Neovim
|
|
70
|
+
|
|
71
|
+
`n` needs nothing in the editor: every Neovim listens on its own socket, and
|
|
72
|
+
the server finds them by `nvim_socket_glob`. Windows showing the file move to
|
|
73
|
+
the section; a buffer that is loaded but hidden moves when you next switch to
|
|
74
|
+
it. `nvimd` with no arguments opens the last target; with arguments it is
|
|
75
|
+
plain `nvim`.
|
|
76
|
+
|
|
77
|
+
For `<leader>h`:
|
|
78
|
+
|
|
79
|
+
```lua
|
|
80
|
+
vim.opt.rtp:append(vim.fn.trim(vim.fn.system({ "docread", "nvim-path" })))
|
|
81
|
+
require("doc_reader").setup({ cmd = { "docread" }, keymap = "<leader>h" })
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Section IDs
|
|
85
|
+
|
|
86
|
+
A heading's ID is its explicit `{#id}` if it has one, otherwise GitHub's slug
|
|
87
|
+
of its text, with `-1`, `-2` for repeats. Give a section an explicit ID when
|
|
88
|
+
links to it must survive a rename.
|
|
89
|
+
|
|
90
|
+
## Agents
|
|
91
|
+
|
|
92
|
+
Documents written by coding agents read best when they follow a few rules:
|
|
93
|
+
stable `{#id}`s, short overviews with depth in nested subsections, figures
|
|
94
|
+
kept with their source, no fabricated data, and questions answered without
|
|
95
|
+
editing. The rules ship as a Claude Code skill:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
docread install-skill # -> ~/.claude/skills/doc-authoring/
|
|
99
|
+
docread install-skill --dir .claude/skills # or per project
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Then tell agents to use it, in your global `~/.claude/CLAUDE.md` (or a
|
|
103
|
+
project's `CLAUDE.md`):
|
|
104
|
+
|
|
105
|
+
```markdown
|
|
106
|
+
## Writing Documents
|
|
107
|
+
|
|
108
|
+
Markdown documents you write for the user (research notes, reports, plans,
|
|
109
|
+
explainers) follow the `doc-authoring` skill — load it before creating or
|
|
110
|
+
editing one.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Other agents: if they read `SKILL.md` skills, point `--dir` at their skills
|
|
114
|
+
directory; otherwise paste the skill's body into `AGENTS.md` / `GEMINI.md`.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nvim-doc-reader"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Keyboard-driven HTML reader for Markdown docs, with jumps into running Neovim"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"markdown-it-py>=2.1,<3",
|
|
13
|
+
# 0.3.x is the line that supports markdown-it-py 2.x. Heading {#id} is our
|
|
14
|
+
# own rule, not the attrs plugin, which does not handle it in 0.3.
|
|
15
|
+
"mdit-py-plugins>=0.3.4,<0.4",
|
|
16
|
+
"aiohttp>=3.8",
|
|
17
|
+
"tomli>=2.0; python_version < '3.11'",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
docread = "doc_reader.cli:main"
|
|
22
|
+
nvimd = "doc_reader.cli:nvimd_main"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.package-data]
|
|
28
|
+
doc_reader = ["static/*", "nvim/lua/*.lua", "skills/*/*.md"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""doc-reader: keyboard-driven HTML reader for Markdown, with Neovim jumps."""
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""`docread` (serve, url, section, nvim-path) and `nvimd`."""
|
|
2
|
+
import argparse
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from importlib import resources
|
|
6
|
+
|
|
7
|
+
from doc_reader import config, model
|
|
8
|
+
from doc_reader.urls import Roots
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _read(path, use_stdin):
|
|
12
|
+
if use_stdin:
|
|
13
|
+
return sys.stdin.read()
|
|
14
|
+
with open(path, encoding="utf-8") as f:
|
|
15
|
+
return f.read()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_serve(cfg, args):
|
|
19
|
+
from doc_reader import serve # aiohttp only when serving
|
|
20
|
+
serve.serve(cfg)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cmd_url(cfg, args):
|
|
24
|
+
"""The reader URL of the section containing `line` -- computed from the
|
|
25
|
+
buffer text when it is piped in, so unsaved edits are accounted for."""
|
|
26
|
+
url = Roots(cfg.roots).url_path(args.path)
|
|
27
|
+
if url is None:
|
|
28
|
+
print(f"{args.path} is not under any root: {sorted(cfg.roots)}",
|
|
29
|
+
file=sys.stderr)
|
|
30
|
+
return 1
|
|
31
|
+
sec = model.section_at(model.parse(_read(args.path, args.stdin)), args.line)
|
|
32
|
+
print(cfg.base_url + url + (f"#{sec.id}" if sec else ""))
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_section(cfg, args):
|
|
37
|
+
"""The 1-based line of section `id` in the text (stdin or file), or exit 1."""
|
|
38
|
+
line = model.line_of(model.parse(_read(args.path, args.stdin)), args.id)
|
|
39
|
+
if line is None:
|
|
40
|
+
return 1
|
|
41
|
+
print(line)
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cmd_nvim_path(cfg, args):
|
|
46
|
+
print(resources.files("doc_reader").joinpath("nvim"))
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_install_skill(cfg, args):
|
|
51
|
+
"""Copy the bundled agent skill(s) into a skills directory."""
|
|
52
|
+
dest = os.path.expanduser(args.dir)
|
|
53
|
+
src = resources.files("doc_reader").joinpath("skills")
|
|
54
|
+
for skill in src.iterdir():
|
|
55
|
+
if not skill.is_dir():
|
|
56
|
+
continue
|
|
57
|
+
os.makedirs(os.path.join(dest, skill.name), exist_ok=True)
|
|
58
|
+
for f in skill.iterdir():
|
|
59
|
+
out = os.path.join(dest, skill.name, f.name)
|
|
60
|
+
with open(out, "w", encoding="utf-8") as fh:
|
|
61
|
+
fh.write(f.read_text(encoding="utf-8"))
|
|
62
|
+
print(out)
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main(argv=None):
|
|
67
|
+
p = argparse.ArgumentParser(prog="docread", description=__doc__)
|
|
68
|
+
p.add_argument("--config", help="config file (default: $DOCREADER_CONFIG or "
|
|
69
|
+
"$XDG_CONFIG_HOME/doc-reader/config.toml)")
|
|
70
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
71
|
+
s = sub.add_parser("serve", help="run the reader server")
|
|
72
|
+
s.add_argument("--port", type=int)
|
|
73
|
+
s.add_argument("--bind")
|
|
74
|
+
s.add_argument("--root", action="append", metavar="NAME=DIR",
|
|
75
|
+
help="replace the configured roots (repeatable)")
|
|
76
|
+
s.set_defaults(fn=cmd_serve)
|
|
77
|
+
s = sub.add_parser("url", help="reader URL for a file and line")
|
|
78
|
+
s.add_argument("path")
|
|
79
|
+
s.add_argument("line", type=int, nargs="?", default=1)
|
|
80
|
+
s.add_argument("--stdin", action="store_true", help="read the text from stdin")
|
|
81
|
+
s.set_defaults(fn=cmd_url)
|
|
82
|
+
s = sub.add_parser("section", help="line of a section id")
|
|
83
|
+
s.add_argument("path")
|
|
84
|
+
s.add_argument("id")
|
|
85
|
+
s.add_argument("--stdin", action="store_true")
|
|
86
|
+
s.set_defaults(fn=cmd_section)
|
|
87
|
+
s = sub.add_parser("nvim-path", help="directory to add to Neovim's runtimepath")
|
|
88
|
+
s.set_defaults(fn=cmd_nvim_path)
|
|
89
|
+
s = sub.add_parser("install-skill",
|
|
90
|
+
help="install the doc-authoring agent skill")
|
|
91
|
+
s.add_argument("--dir", default="~/.claude/skills",
|
|
92
|
+
help="skills directory (default: ~/.claude/skills)")
|
|
93
|
+
s.set_defaults(fn=cmd_install_skill)
|
|
94
|
+
args = p.parse_args(argv)
|
|
95
|
+
overrides = {"port": getattr(args, "port", None),
|
|
96
|
+
"bind": getattr(args, "bind", None)}
|
|
97
|
+
if getattr(args, "root", None):
|
|
98
|
+
overrides["roots"] = config.parse_roots(":".join(args.root))
|
|
99
|
+
cfg = config.load(args.config, overrides=overrides)
|
|
100
|
+
return args.fn(cfg, args) or 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def nvimd_main(argv=None):
|
|
104
|
+
from doc_reader import nvim
|
|
105
|
+
return nvim.nvimd(config.load(), sys.argv[1:] if argv is None else argv)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
sys.exit(main())
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Settings: defaults, then the TOML file, then DOCREADER_* env, then CLI flags.
|
|
2
|
+
|
|
3
|
+
Nothing here names a host or a directory of ours. The defaults are the
|
|
4
|
+
generic ones (XDG dirs, the home directory as the only root), and a site
|
|
5
|
+
config supplies the rest.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import pwd
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
if sys.version_info >= (3, 11):
|
|
15
|
+
import tomllib
|
|
16
|
+
else:
|
|
17
|
+
import tomli as tomllib
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _xdg(var, fallback):
|
|
21
|
+
return os.environ.get(var) or os.path.expanduser(fallback)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _runtime_dir():
|
|
25
|
+
if os.environ.get("XDG_RUNTIME_DIR"):
|
|
26
|
+
return os.environ["XDG_RUNTIME_DIR"]
|
|
27
|
+
run = f"/run/user/{os.getuid()}"
|
|
28
|
+
return run if os.path.isdir(run) else "/tmp"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_socket_glob():
|
|
32
|
+
"""Where Neovim puts the socket it opens on startup: its stdpath("run"),
|
|
33
|
+
which is $XDG_RUNTIME_DIR or else a private random directory under the
|
|
34
|
+
temp dir (e.g. /tmp/nvim.root/8CVDuf/nvim.3950.0)."""
|
|
35
|
+
if os.environ.get("XDG_RUNTIME_DIR"):
|
|
36
|
+
return os.path.join(os.environ["XDG_RUNTIME_DIR"], "nvim.*.0")
|
|
37
|
+
user = pwd.getpwuid(os.getuid()).pw_name
|
|
38
|
+
return os.path.join(tempfile.gettempdir(), f"nvim.{user}", "*", "nvim.*.0")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def expand(value):
|
|
42
|
+
"""`~` and `$VAR`, with the XDG vars filled in when the environment lacks
|
|
43
|
+
them, so `$XDG_STATE_HOME/...` never expands to a path starting at `/`."""
|
|
44
|
+
env = dict(os.environ)
|
|
45
|
+
env.setdefault("XDG_CONFIG_HOME", _xdg("XDG_CONFIG_HOME", "~/.config"))
|
|
46
|
+
env.setdefault("XDG_STATE_HOME", _xdg("XDG_STATE_HOME", "~/.local/state"))
|
|
47
|
+
env.setdefault("XDG_RUNTIME_DIR", _runtime_dir())
|
|
48
|
+
out = os.path.expanduser(str(value))
|
|
49
|
+
for k in sorted(env, key=len, reverse=True):
|
|
50
|
+
out = out.replace("${" + k + "}", env[k]).replace("$" + k, env[k])
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Config:
|
|
56
|
+
roots: dict = field(default_factory=lambda: {"home": os.path.expanduser("~")})
|
|
57
|
+
bind: str = "0.0.0.0"
|
|
58
|
+
port: int = 8765
|
|
59
|
+
public_url: str = ""
|
|
60
|
+
nvim: str = "nvim"
|
|
61
|
+
nvim_socket_glob: str = "" # empty: default_socket_glob()
|
|
62
|
+
state_file: str = "$XDG_STATE_HOME/doc-reader/last"
|
|
63
|
+
poll_interval_s: float = 0.5
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def base_url(self):
|
|
67
|
+
return (self.public_url or f"http://localhost:{self.port}").rstrip("/")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
SCALARS = {"bind": str, "port": int, "public_url": str, "nvim": str,
|
|
71
|
+
"nvim_socket_glob": str,
|
|
72
|
+
"state_file": str, "poll_interval_s": float}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def config_path():
|
|
76
|
+
if os.environ.get("DOCREADER_CONFIG"):
|
|
77
|
+
return Path(expand(os.environ["DOCREADER_CONFIG"]))
|
|
78
|
+
return Path(expand("$XDG_CONFIG_HOME/doc-reader/config.toml"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_roots(spec):
|
|
82
|
+
"""`name=/dir:name=/dir` (the DOCREADER_ROOTS form)."""
|
|
83
|
+
roots = {}
|
|
84
|
+
for part in filter(None, spec.split(":")):
|
|
85
|
+
name, sep, path = part.partition("=")
|
|
86
|
+
if not sep or not name or not path:
|
|
87
|
+
raise ValueError(f"bad root {part!r}: expected name=/dir")
|
|
88
|
+
roots[name] = path
|
|
89
|
+
return roots
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def load(path=None, env=None, overrides=None):
|
|
93
|
+
env = os.environ if env is None else env
|
|
94
|
+
cfg = Config()
|
|
95
|
+
path = Path(path) if path else config_path()
|
|
96
|
+
data = {}
|
|
97
|
+
if path.exists():
|
|
98
|
+
with open(path, "rb") as f:
|
|
99
|
+
data = tomllib.load(f)
|
|
100
|
+
unknown = set(data) - set(SCALARS) - {"roots"}
|
|
101
|
+
if unknown:
|
|
102
|
+
raise ValueError(f"{path}: unknown keys {sorted(unknown)}")
|
|
103
|
+
for key, kind in SCALARS.items():
|
|
104
|
+
if key in data:
|
|
105
|
+
setattr(cfg, key, kind(data[key]))
|
|
106
|
+
if env.get(f"DOCREADER_{key.upper()}"):
|
|
107
|
+
setattr(cfg, key, kind(env[f"DOCREADER_{key.upper()}"]))
|
|
108
|
+
if "roots" in data:
|
|
109
|
+
if not isinstance(data["roots"], dict):
|
|
110
|
+
raise ValueError(f"{path}: [roots] must be a table of name = \"/dir\"")
|
|
111
|
+
cfg.roots = dict(data["roots"])
|
|
112
|
+
if env.get("DOCREADER_ROOTS"):
|
|
113
|
+
cfg.roots = parse_roots(env["DOCREADER_ROOTS"])
|
|
114
|
+
for key, value in (overrides or {}).items():
|
|
115
|
+
if value is not None:
|
|
116
|
+
setattr(cfg, key, value)
|
|
117
|
+
for name in cfg.roots:
|
|
118
|
+
if "/" in name or not name:
|
|
119
|
+
raise ValueError(f"root name {name!r} must be a single path segment")
|
|
120
|
+
cfg.roots = {k: os.path.realpath(expand(v)) for k, v in cfg.roots.items()}
|
|
121
|
+
cfg.nvim_socket_glob = (expand(cfg.nvim_socket_glob) if cfg.nvim_socket_glob
|
|
122
|
+
else default_socket_glob())
|
|
123
|
+
cfg.state_file = os.path.abspath(expand(cfg.state_file))
|
|
124
|
+
return cfg
|