dotslot 0.2.1__py3-none-any.whl
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.
- dotslot/__init__.py +3 -0
- dotslot/__main__.py +3 -0
- dotslot/cli.py +204 -0
- dotslot/core.py +284 -0
- dotslot/mcp.py +77 -0
- dotslot/verify.py +58 -0
- dotslot/width.py +120 -0
- dotslot-0.2.1.dist-info/METADATA +108 -0
- dotslot-0.2.1.dist-info/RECORD +13 -0
- dotslot-0.2.1.dist-info/WHEEL +5 -0
- dotslot-0.2.1.dist-info/entry_points.txt +3 -0
- dotslot-0.2.1.dist-info/licenses/LICENSE +21 -0
- dotslot-0.2.1.dist-info/top_level.txt +1 -0
dotslot/__init__.py
ADDED
dotslot/__main__.py
ADDED
dotslot/cli.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""dotslot scan | fill | check | fit"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import argparse, json, subprocess, sys
|
|
4
|
+
from .core import scan_text, fill_text, SlotError
|
|
5
|
+
from .verify import verify
|
|
6
|
+
from .width import layout, preview, legacy_ends, display_width
|
|
7
|
+
|
|
8
|
+
def read(p): return sys.stdin.read() if p == '-' else open(p, encoding='utf-8').read()
|
|
9
|
+
|
|
10
|
+
def parse_sets(items):
|
|
11
|
+
out = {}
|
|
12
|
+
for it in items or []:
|
|
13
|
+
k, sep, v = it.partition('=')
|
|
14
|
+
if not sep: raise SystemExit(f'--set needs name=value, got {it!r}')
|
|
15
|
+
out[k] = v
|
|
16
|
+
return out
|
|
17
|
+
|
|
18
|
+
def cmd_scan(a):
|
|
19
|
+
rows = []
|
|
20
|
+
for p in a.files:
|
|
21
|
+
slots, issues = scan_text(read(p), p, a.mode)
|
|
22
|
+
for s in slots:
|
|
23
|
+
rows.append(dict(file=p, line=s.line + 1, col=s.col, index=s.index, name=s.name, role=s.role,
|
|
24
|
+
width=s.width, lines=1 + len(s.conts), capacity=s.capacity + sum(c.capacity for c in s.conts),
|
|
25
|
+
filled=s.filled, value=(s.block_value() if s.conts else s.value) if s.filled else None,
|
|
26
|
+
policy=s.policy, in_comment=s.in_comment))
|
|
27
|
+
for sev, l, c, m in issues: print(f'{p}:{l+1}:{c}: {sev}: {m}', file=sys.stderr)
|
|
28
|
+
if a.json: print(json.dumps(rows, ensure_ascii=False, indent=1)); return 0
|
|
29
|
+
for r in rows:
|
|
30
|
+
v = '' if r['value'] is None else repr(r['value'])
|
|
31
|
+
print(f"{r['file']}:{r['line']}:{r['col']} @{r['index']} {r['name'] or '-':12} {r['role']:6} "
|
|
32
|
+
f"w={r['width']:<3} x{r['lines']} cap={r['capacity']:<3} {'filled' if r['filled'] else 'empty ':6} "
|
|
33
|
+
f"{r['policy'] or '':8} {'#' if r['in_comment'] else ' '} {v}")
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
def cmd_fill(a):
|
|
37
|
+
vals = parse_sets(a.set)
|
|
38
|
+
if a.json_values: vals.update(json.load(open(a.json_values)))
|
|
39
|
+
rc = 0
|
|
40
|
+
allow = set(a.allow.split(',')) if a.allow else None
|
|
41
|
+
for p in a.files:
|
|
42
|
+
try:
|
|
43
|
+
out = fill_text(read(p), vals, p, a.mode, a.overflow, tuple(a.clear or ()), allow)
|
|
44
|
+
except SlotError as e:
|
|
45
|
+
print(f'{p}: error: {e}', file=sys.stderr); rc = 2; continue
|
|
46
|
+
if a.in_place and p != '-':
|
|
47
|
+
open(p, 'w', encoding='utf-8').write(out)
|
|
48
|
+
else:
|
|
49
|
+
sys.stdout.write(out)
|
|
50
|
+
return rc
|
|
51
|
+
|
|
52
|
+
def fit_report(text, path, mode, targets, tab, show_preview):
|
|
53
|
+
slots, _ = scan_text(text, path, mode)
|
|
54
|
+
lines = text.split('\n'); out = []; bad = {t: 0 for t in targets}
|
|
55
|
+
by_line = {}
|
|
56
|
+
for s in slots:
|
|
57
|
+
for x in [s] + s.conts: by_line.setdefault(x.line, []).append((s, x))
|
|
58
|
+
for li in sorted(by_line):
|
|
59
|
+
ln = lines[li]
|
|
60
|
+
cl = layout([(ln, 'line')], tab)
|
|
61
|
+
lw = cl[-1].col + cl[-1].width if cl else 0
|
|
62
|
+
leg = legacy_ends([(ln, 'line')], tab)[-1]
|
|
63
|
+
grows = any(h.policy == 'grow' for h, _ in by_line[li])
|
|
64
|
+
for head, x in by_line[li]:
|
|
65
|
+
end = display_width(ln[:x.end], 0, tab)
|
|
66
|
+
st = {}
|
|
67
|
+
for t in targets:
|
|
68
|
+
s_ = 'over' if end > t else 'pushes' if lw > t else 'maybe' if leg > t else 'ok'
|
|
69
|
+
st[t] = s_
|
|
70
|
+
if s_ != 'ok': bad[t] += 1
|
|
71
|
+
label = (head.name or f'@{head.index}') + ('' if x is head else f'[+{x.line - head.line}]')
|
|
72
|
+
out.append(f"{path}:{li+1}: slot {label} col {x.col}..{end} (line {lw}{'' if leg == lw else f', per-codepoint {leg}'})"
|
|
73
|
+
f" {'grow' if grows else 'static'} " + ' '.join(f'{t}:{v}' for t, v in st.items()))
|
|
74
|
+
if show_preview:
|
|
75
|
+
for t in targets:
|
|
76
|
+
if st[t] in ('over', 'pushes') and (p := preview(cl, t)):
|
|
77
|
+
out.append(f" @{t}: {p['note']}" + (' (2-cell glyph straddles edge)' if p['straddle'] else ''))
|
|
78
|
+
out.append(f" word │{p['word'][0]}\n │{p['word'][1]}")
|
|
79
|
+
return out, bad
|
|
80
|
+
|
|
81
|
+
def cmd_fit(a):
|
|
82
|
+
targets = sorted(int(t) for t in a.targets.split(','))
|
|
83
|
+
rc = 0
|
|
84
|
+
for p in a.files:
|
|
85
|
+
text = read(p)
|
|
86
|
+
if a.set:
|
|
87
|
+
try: text = fill_text(text, parse_sets(a.set), p, a.mode, a.overflow)
|
|
88
|
+
except SlotError as e: print(f'{p}: error: {e}', file=sys.stderr); rc = 2; continue
|
|
89
|
+
out, bad = fit_report(text, p, a.mode, targets, a.tab, a.preview)
|
|
90
|
+
print('\n'.join(out)) if out else None
|
|
91
|
+
if bad.get(a.fail_at or targets[0]): rc = max(rc, 1)
|
|
92
|
+
return rc
|
|
93
|
+
|
|
94
|
+
def base_text(a, p):
|
|
95
|
+
if a.base: return read(a.base)
|
|
96
|
+
if a.git_base:
|
|
97
|
+
r = subprocess.run(['git', 'show', f'{a.git_base}:./{p}'], capture_output=True, text=True,
|
|
98
|
+
cwd=None)
|
|
99
|
+
return r.stdout if r.returncode == 0 else None # new file: nothing to compare
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def cmd_new(a):
|
|
103
|
+
pol = a.policy or ''
|
|
104
|
+
head = f'{a.name}{pol}:' if a.name else (f'{pol}:' if pol else '')
|
|
105
|
+
if a.width < len(head) + 1: raise SystemExit(f'width {a.width} leaves no room after {head!r}')
|
|
106
|
+
pre, post = a.comment, (' ' + CLOSE[a.comment.strip()] if a.comment.strip() in CLOSE else '')
|
|
107
|
+
lines = [pre + '<./' + head + a.pad * (a.width - len(head)) + '>' + post]
|
|
108
|
+
for _ in range(a.lines - 1):
|
|
109
|
+
lines.append(pre + '<./|' + a.pad * (a.width - 1) + '>' + post)
|
|
110
|
+
if a.md: lines = ['`' + l + '`' for l in lines]
|
|
111
|
+
print('\n'.join(lines)); return 0
|
|
112
|
+
|
|
113
|
+
CLOSE = {'<!--': '-->', '/*': '*/'}
|
|
114
|
+
|
|
115
|
+
def cmd_repad(a):
|
|
116
|
+
from .core import scan_text as sc, render
|
|
117
|
+
base, text = read(a.base), read(a.files[0])
|
|
118
|
+
bs, _ = sc(base, a.files[0], a.mode); ns, _ = sc(text, a.files[0], a.mode)
|
|
119
|
+
if len(bs) != len(ns): print('slot count differs; cannot repad', file=sys.stderr); return 2
|
|
120
|
+
lines = text.split('\n'); fixed = 0
|
|
121
|
+
for b, n in sorted(zip(bs, ns), key=lambda p: (p[1].line, -p[1].start)):
|
|
122
|
+
if b.width != n.width and not n.conts and b.policy != 'grow' and b.header == n.header:
|
|
123
|
+
if len(n.raw_value) and __import__('dotslot.width', fromlist=['cells']).cells(n.header + n.raw_value) <= b.width:
|
|
124
|
+
ln = lines[n.line]
|
|
125
|
+
lines[n.line] = ln[:n.start] + '<./' + render(n.header, n.raw_value, b.width, b.pad) + '>' + ln[n.end:]
|
|
126
|
+
fixed += 1
|
|
127
|
+
out = '\n'.join(lines)
|
|
128
|
+
if a.in_place: open(a.files[0], 'w', encoding='utf-8').write(out)
|
|
129
|
+
else: sys.stdout.write(out)
|
|
130
|
+
print(f'repadded {fixed} slot(s)', file=sys.stderr); return 0
|
|
131
|
+
|
|
132
|
+
def cmd_check(a):
|
|
133
|
+
rc = 0
|
|
134
|
+
req = set((a.require or '').split(',')) - {''}
|
|
135
|
+
allow = set(a.allow.split(',')) if a.allow else None
|
|
136
|
+
reports = []
|
|
137
|
+
for p in a.files:
|
|
138
|
+
text = read(p)
|
|
139
|
+
b = base_text(a, p)
|
|
140
|
+
if b is not None:
|
|
141
|
+
r = verify(b, text, p, a.mode, allow); r['file'] = p; reports.append(r)
|
|
142
|
+
if not a.json:
|
|
143
|
+
for c in r['changed']: print(f"{p}:{c['line']}: filled {c['slot']}: {c['before']!r} -> {c['after']!r}")
|
|
144
|
+
for x in r['violations']: print(f'{p}: VIOLATION: {x}')
|
|
145
|
+
print(f"{p}: {'fill-safe' if r['ok'] else 'NOT fill-safe'} ({len(r['changed'])} slot(s) changed)")
|
|
146
|
+
if not r['ok']: rc = 1
|
|
147
|
+
continue
|
|
148
|
+
slots, issues = scan_text(text, p, a.mode)
|
|
149
|
+
probs = [(sev, l, c, m) for sev, l, c, m in issues]
|
|
150
|
+
for s in slots:
|
|
151
|
+
if (a.require_filled or (s.name in req)) and not s.filled:
|
|
152
|
+
probs.append(('E', s.line, s.col, f'{s.name or "@%d" % s.index} is empty'))
|
|
153
|
+
names = {s.name for s in slots}
|
|
154
|
+
for n in req - names: probs.append(('E', 0, 0, f'required slot {n!r} not found'))
|
|
155
|
+
if a.fit:
|
|
156
|
+
out, bad = fit_report(text, p, a.mode, [a.fit], a.tab, False)
|
|
157
|
+
for o in out:
|
|
158
|
+
if f'{a.fit}:ok' not in o: probs.append(('E', 0, 0, 'fit: ' + o.split(': ', 1)[1]))
|
|
159
|
+
for sev, l, c, m in probs:
|
|
160
|
+
if not a.json: print(f'{p}:{l+1}:{c}: {sev}: {m}')
|
|
161
|
+
if sev == 'E' or (a.strict and sev == 'W'): rc = 1
|
|
162
|
+
if not probs and not a.json: print(f'{p}: ok ({len(slots)} slots)')
|
|
163
|
+
if a.json: print(json.dumps(reports, ensure_ascii=False, indent=1))
|
|
164
|
+
return rc
|
|
165
|
+
|
|
166
|
+
def main(argv=None):
|
|
167
|
+
ap = argparse.ArgumentParser(prog='dotslot', description='fixed-width <./____> slots (v0.2)')
|
|
168
|
+
sub = ap.add_subparsers(dest='cmd', required=True)
|
|
169
|
+
def common(sp):
|
|
170
|
+
sp.add_argument('files', nargs='+')
|
|
171
|
+
sp.add_argument('--mode', choices=['auto', 'prose', 'comments'], default='auto',
|
|
172
|
+
help='auto: bare slots only in prose files (.md .txt …); everything else comment-only')
|
|
173
|
+
s = sub.add_parser('scan'); common(s); s.add_argument('--json', action='store_true')
|
|
174
|
+
for name in ('fill', 'fit'):
|
|
175
|
+
f = sub.add_parser(name); common(f)
|
|
176
|
+
f.add_argument('--set', action='append', help='name=value or @N=value')
|
|
177
|
+
f.add_argument('--overflow', choices=['error', 'ellipsis', 'truncate', 'grow'], default='error',
|
|
178
|
+
help='policy for slots without a !/~/+ marker')
|
|
179
|
+
f.add_argument('--allow', help='comma list of slot names/@N that may be filled')
|
|
180
|
+
if name == 'fill':
|
|
181
|
+
f.add_argument('--json-values'); f.add_argument('-i', '--in-place', action='store_true')
|
|
182
|
+
f.add_argument('--clear', action='append', help='empty a slot again (name or @N)')
|
|
183
|
+
else:
|
|
184
|
+
f.add_argument('--targets', default='80,100,120'); f.add_argument('--tab', type=int, default=8)
|
|
185
|
+
f.add_argument('--preview', action='store_true'); f.add_argument('--fail-at', type=int)
|
|
186
|
+
n = sub.add_parser('new', help='print a correctly sized slot or block')
|
|
187
|
+
n.add_argument('name', nargs='?'); n.add_argument('--width', type=int, required=True)
|
|
188
|
+
n.add_argument('--lines', type=int, default=1); n.add_argument('--policy', choices=['!', '~', '+'])
|
|
189
|
+
n.add_argument('--comment', default='', help="prefix such as '# ' or '<!-- '"); n.add_argument('--pad', default='_')
|
|
190
|
+
n.add_argument('--md', action='store_true', help='wrap in backticks (Markdown code span)')
|
|
191
|
+
n.set_defaults(indent_only=False)
|
|
192
|
+
r = sub.add_parser('repad', help='restore slot widths from a base file (fixes agents that drop padding)')
|
|
193
|
+
r.add_argument('files', nargs=1); r.add_argument('--base', required=True); r.add_argument('-i', '--in-place', action='store_true')
|
|
194
|
+
r.add_argument('--mode', choices=['auto', 'prose', 'comments'], default='auto')
|
|
195
|
+
c = sub.add_parser('check'); common(c)
|
|
196
|
+
c.add_argument('--require', help='comma list of names that must be filled')
|
|
197
|
+
c.add_argument('--require-filled', action='store_true'); c.add_argument('--strict', action='store_true')
|
|
198
|
+
c.add_argument('--fit', type=int, help='also fail lines wider than N cells'); c.add_argument('--tab', type=int, default=8)
|
|
199
|
+
c.add_argument('--base', help='fill-safe check: compare against this earlier version of the file')
|
|
200
|
+
c.add_argument('--git-base', help='fill-safe check against a git revision (e.g. HEAD, origin/main)')
|
|
201
|
+
c.add_argument('--allow', help='with --base: only these slots may change')
|
|
202
|
+
c.add_argument('--json', action='store_true')
|
|
203
|
+
a = ap.parse_args(argv)
|
|
204
|
+
return dict(scan=cmd_scan, fill=cmd_fill, check=cmd_check, fit=cmd_fit, new=cmd_new, repad=cmd_repad)[a.cmd](a)
|
dotslot/core.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
r"""dotslot v0.2 — fixed-width fill-in slots `<./________>`.
|
|
2
|
+
|
|
3
|
+
Interior (between `<./` and `>`), width W = its display cells in the file:
|
|
4
|
+
empty <./________> padding only ('_' or ' ')
|
|
5
|
+
named <./status:____> IDENT [policy] ':' padding
|
|
6
|
+
filled <./status:done__> header + value + padding
|
|
7
|
+
<./done_____> unnamed value: no header, no cost
|
|
8
|
+
policy <./~:______> unnamed slot with a policy
|
|
9
|
+
cont <./|more text___> block continuation
|
|
10
|
+
policy ! error (default) ~ ellipsis + grow
|
|
11
|
+
Escapes inside a value: \\ \< \> \_ \: \| (the tool writes them for you)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
import os, re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from .width import cells, cut, display_width
|
|
17
|
+
|
|
18
|
+
SLOT = re.compile(r'<\./((?:\\.|[^<>\\\n])*)>')
|
|
19
|
+
OPEN = re.compile(r'<\./')
|
|
20
|
+
IDENT = r'[A-Za-z_][\w-]*'
|
|
21
|
+
HEADER = re.compile(rf'(?:({IDENT})([!~+]?)|([!~+])):')
|
|
22
|
+
PAD = ' _'
|
|
23
|
+
POLICY = {'!': 'error', '~': 'ellipsis', '+': 'grow', '': None}
|
|
24
|
+
MARK = {v: k for k, v in POLICY.items() if v}
|
|
25
|
+
PROSE = {'.md', '.markdown', '.txt', '.text', '.rst', '.adoc', '.org', '.slot'}
|
|
26
|
+
COMMENT = {
|
|
27
|
+
**{e: ('#',) for e in '.py .sh .bash .zsh .yaml .yml .toml .rb .pl .r .mk .cfg .conf .dockerfile'.split()},
|
|
28
|
+
**{e: ('//', '/*') for e in '.js .mjs .cjs .ts .tsx .jsx .c .h .cc .cpp .go .rs .java .kt .swift .cs .css .scss .json5'.split()},
|
|
29
|
+
**{e: ('--',) for e in '.sql .lua .hs .elm'.split()},
|
|
30
|
+
**{e: (';',) for e in '.ini .el .lisp .clj .scm .asm'.split()},
|
|
31
|
+
**{e: ('<!--',) for e in '.html .htm .xml .svg .vue'.split()},
|
|
32
|
+
}
|
|
33
|
+
ALL_MARKERS = ('#', '//', '/*', '--', ';', '<!--')
|
|
34
|
+
CLOSERS = ('-->', '*/')
|
|
35
|
+
|
|
36
|
+
class SlotError(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def unescape(s): return re.sub(r'\\(.)', r'\1', s)
|
|
40
|
+
|
|
41
|
+
def escape(v: str, unnamed: bool) -> str:
|
|
42
|
+
v = v.replace('\\', '\\\\').replace('<', '\\<').replace('>', '\\>')
|
|
43
|
+
if unnamed:
|
|
44
|
+
if v[:1] == '|': v = '\\' + v
|
|
45
|
+
m = HEADER.match(v)
|
|
46
|
+
if m: v = v[:m.end() - 1] + '\\:' + v[m.end():]
|
|
47
|
+
if v and v[-1] in PAD: v = v[:-1] + '\\' + v[-1]
|
|
48
|
+
return v
|
|
49
|
+
|
|
50
|
+
def split_pad(s):
|
|
51
|
+
"""-> (body, padding) where padding is the trailing run of unescaped ' '/'_'."""
|
|
52
|
+
i = len(s)
|
|
53
|
+
while i > 0 and s[i - 1] in PAD:
|
|
54
|
+
# stop if this pad char is escaped (odd number of backslashes before it)
|
|
55
|
+
j, bs = i - 2, 0
|
|
56
|
+
while j >= 0 and s[j] == '\\': bs += 1; j -= 1
|
|
57
|
+
if bs % 2: break
|
|
58
|
+
i -= 1
|
|
59
|
+
return s[:i], s[i:]
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Slot:
|
|
63
|
+
line: int; start: int; end: int; col: int; interior: str
|
|
64
|
+
kind: str # empty | named | filled | cont
|
|
65
|
+
name: str | None = None
|
|
66
|
+
policy: str | None = None
|
|
67
|
+
header: str = '' # raw header incl ':' (or '|' for cont)
|
|
68
|
+
raw_value: str = '' # escaped form as in the file
|
|
69
|
+
pad: str = '_'
|
|
70
|
+
in_comment: bool = False
|
|
71
|
+
role: str = 'inline'
|
|
72
|
+
conts: list = field(default_factory=list)
|
|
73
|
+
index: int = 0
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def width(self): return cells(self.interior)
|
|
77
|
+
@property
|
|
78
|
+
def capacity(self): return self.width - cells(self.header)
|
|
79
|
+
@property
|
|
80
|
+
def value(self): return unescape(self.raw_value) if self.raw_value else None
|
|
81
|
+
@property
|
|
82
|
+
def filled(self): return bool(self.raw_value)
|
|
83
|
+
def block_value(self):
|
|
84
|
+
return ' '.join(p for p in [self.value or ''] + [c.value or '' for c in self.conts] if p)
|
|
85
|
+
|
|
86
|
+
def parse_interior(s: str):
|
|
87
|
+
if '\t' in s: return None
|
|
88
|
+
body, padding = split_pad(s)
|
|
89
|
+
pad = '_' if '_' in padding else (' ' if padding else '_')
|
|
90
|
+
if body.startswith('|'):
|
|
91
|
+
return dict(kind='cont', header='|', raw_value=body[1:], pad=pad)
|
|
92
|
+
m = HEADER.match(body)
|
|
93
|
+
if m:
|
|
94
|
+
name, pol = m.group(1), POLICY[m.group(2) or m.group(3) or '']
|
|
95
|
+
rest = body[m.end():]
|
|
96
|
+
return dict(kind='filled' if rest else ('named' if name else 'empty'), name=name, policy=pol,
|
|
97
|
+
header=m.group(0), raw_value=rest, pad=pad)
|
|
98
|
+
if not body:
|
|
99
|
+
return dict(kind='empty', pad=pad) if padding else None
|
|
100
|
+
return dict(kind='filled', raw_value=body, pad=pad)
|
|
101
|
+
|
|
102
|
+
def comment_start(line: str, markers) -> int:
|
|
103
|
+
"""Index of the first comment marker outside '…' "…" `…` strings, or -1."""
|
|
104
|
+
q, i, n = None, 0, len(line)
|
|
105
|
+
while i < n:
|
|
106
|
+
c = line[i]
|
|
107
|
+
if q:
|
|
108
|
+
if c == '\\': i += 2; continue
|
|
109
|
+
if c == q: q = None
|
|
110
|
+
elif c in '"\'`':
|
|
111
|
+
# treat ' as a quote only when it pairs later on the line (Rust lifetimes, prose)
|
|
112
|
+
if c != "'" or line.find("'", i + 1) != -1: q = c
|
|
113
|
+
else:
|
|
114
|
+
for mk in markers:
|
|
115
|
+
if line.startswith(mk, i): return i
|
|
116
|
+
i += 1
|
|
117
|
+
return -1
|
|
118
|
+
|
|
119
|
+
def markers_for(path, mode):
|
|
120
|
+
ext = os.path.splitext(path or '')[1].lower()
|
|
121
|
+
base = os.path.basename(path or '').lower()
|
|
122
|
+
if mode == 'prose': return None
|
|
123
|
+
if mode == 'auto' and (ext in PROSE or path in ('-', '/dev/stdin')): return None
|
|
124
|
+
if base in ('dockerfile', 'makefile'): return ('#',)
|
|
125
|
+
return COMMENT.get(ext, ALL_MARKERS)
|
|
126
|
+
|
|
127
|
+
def scan_text(text: str, path: str = '-', mode: str = 'auto'):
|
|
128
|
+
lines = text.split('\n')
|
|
129
|
+
markers = markers_for(path, mode)
|
|
130
|
+
slots, issues = [], []
|
|
131
|
+
for i, ln in enumerate(lines):
|
|
132
|
+
taken = set()
|
|
133
|
+
cpos = comment_start(ln, markers) if markers else -1
|
|
134
|
+
for m in SLOT.finditer(ln):
|
|
135
|
+
taken.add(m.start())
|
|
136
|
+
col = display_width(ln[:m.start()])
|
|
137
|
+
if m.group(1) == '':
|
|
138
|
+
issues.append(('E', i, col, 'zero-width slot <./>')); continue
|
|
139
|
+
p = parse_interior(m.group(1))
|
|
140
|
+
if p is None:
|
|
141
|
+
issues.append(('W', i, col, f'slot-like text {m.group(0)!r} is not a valid slot (ignored)')); continue
|
|
142
|
+
if markers is not None and not (0 <= cpos < m.start()):
|
|
143
|
+
issues.append(('W', i, col, f'{m.group(0)!r} is outside a comment (ignored; use --mode prose)')); continue
|
|
144
|
+
s = Slot(i, m.start(), m.end(), col, m.group(1), in_comment=markers is not None, **p)
|
|
145
|
+
if (os.path.splitext(path)[1].lower() in ('.md', '.markdown') and '_' in m.group(1)
|
|
146
|
+
and ln[:m.start()].count('`') % 2 == 0):
|
|
147
|
+
issues.append(('W', i, col, f"{m.group(0)!r}: '_' outside a code span can render as emphasis in Markdown; wrap it in backticks"))
|
|
148
|
+
if s.kind == 'cont': s.role = 'cont'
|
|
149
|
+
slots.append(s)
|
|
150
|
+
for m in OPEN.finditer(ln):
|
|
151
|
+
if m.start() not in taken and (markers is None or 0 <= cpos < m.start()):
|
|
152
|
+
issues.append(('E', i, display_width(ln[:m.start()]), "unterminated slot: '<./' without '>' on the same line"))
|
|
153
|
+
by_line = {}
|
|
154
|
+
for s in slots: by_line.setdefault(s.line, []).append(s)
|
|
155
|
+
owned = set()
|
|
156
|
+
for s in slots:
|
|
157
|
+
if s.kind in ('named', 'filled') and s.name:
|
|
158
|
+
j = s.line + 1
|
|
159
|
+
while True:
|
|
160
|
+
c = next((x for x in by_line.get(j, []) if x.kind == 'cont' and x.col == s.col), None)
|
|
161
|
+
if not c: break
|
|
162
|
+
if c.width != s.width:
|
|
163
|
+
issues.append(('E', j, c.col, f'block {s.name}: continuation width {c.width} != head width {s.width}')); break
|
|
164
|
+
owned.add(id(c)); s.conts.append(c); j += 1
|
|
165
|
+
if s.conts: s.role = 'head'
|
|
166
|
+
for s in slots:
|
|
167
|
+
if s.kind == 'cont' and id(s) not in owned:
|
|
168
|
+
issues.append(('E', s.line, s.col, 'continuation <./|…> with no named slot directly above at the same column'))
|
|
169
|
+
top = [s for s in slots if s.role != 'cont']
|
|
170
|
+
widths = {}
|
|
171
|
+
for k, s in enumerate(top):
|
|
172
|
+
s.index = k + 1
|
|
173
|
+
if s.name: widths.setdefault(s.name, set()).add(s.width)
|
|
174
|
+
if s.capacity < 1 and s.policy != 'grow':
|
|
175
|
+
issues.append(('E', s.line, s.col, f'{s.name or "slot"}: header leaves {s.capacity} cells for a value (width {s.width})'))
|
|
176
|
+
for n, ws in widths.items():
|
|
177
|
+
if len(ws) > 1: issues.append(('W', 0, 0, f'name {n!r} used with different widths {sorted(ws)}'))
|
|
178
|
+
return top, issues
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------- fill
|
|
181
|
+
def clean(v: str, block: bool):
|
|
182
|
+
v = str(v)
|
|
183
|
+
if block: v = re.sub(r'\s*\n\s*', ' ', v)
|
|
184
|
+
if '\t' in v or '\n' in v:
|
|
185
|
+
raise SlotError('value contains a tab or newline; inline slots hold one line')
|
|
186
|
+
return v.rstrip(' ')
|
|
187
|
+
|
|
188
|
+
def render(header, raw, width, pad):
|
|
189
|
+
body = header + raw
|
|
190
|
+
return body + pad * max(width - cells(body), 0)
|
|
191
|
+
|
|
192
|
+
def cut_escaped(v, cap, unnamed, ellipsis):
|
|
193
|
+
"""Cut the *unescaped* value so its escaped form fits cap; prefer a word boundary for ellipsis."""
|
|
194
|
+
lo, best = 0, ''
|
|
195
|
+
for k in range(len(v), -1, -1):
|
|
196
|
+
head = v[:k].rstrip(' ') if ellipsis else v[:k]
|
|
197
|
+
e = escape(head, unnamed) + ('…' if ellipsis else '')
|
|
198
|
+
if cells(e) <= cap:
|
|
199
|
+
best = head; break
|
|
200
|
+
if ellipsis and ' ' in best:
|
|
201
|
+
wb = best[:best.rfind(' ')].rstrip(' ')
|
|
202
|
+
if wb and cells(wb) >= (cap * 2) // 3: best = wb
|
|
203
|
+
return escape(best, unnamed) + ('…' if ellipsis else '')
|
|
204
|
+
|
|
205
|
+
def fit_value(v, cap, policy, what, unnamed):
|
|
206
|
+
e = escape(v, unnamed)
|
|
207
|
+
if cells(e) <= cap: return e
|
|
208
|
+
if policy == 'ellipsis':
|
|
209
|
+
if cap < 2: raise SlotError(f'{what}: no room for an ellipsis')
|
|
210
|
+
return cut_escaped(v, cap, unnamed, True)
|
|
211
|
+
if policy == 'truncate': return cut_escaped(v, cap, unnamed, False)
|
|
212
|
+
if policy == 'grow': return e
|
|
213
|
+
raise SlotError(f'{what}: value is {cells(e)} cells, slot holds {cap} (overflow policy: error)')
|
|
214
|
+
|
|
215
|
+
def wrap_words(v, caps):
|
|
216
|
+
words, lines, k = [w for w in v.split(' ') if w], [], 0
|
|
217
|
+
for cap in caps:
|
|
218
|
+
cur = ''
|
|
219
|
+
while k < len(words):
|
|
220
|
+
w = words[k]; cand = (cur + ' ' + w) if cur else w
|
|
221
|
+
if cells(escape(cand, False)) <= cap: cur, k = cand, k + 1
|
|
222
|
+
elif not cur and cells(escape(w, False)) > cap:
|
|
223
|
+
head, _ = cut(w, max(cap - 1, 1))
|
|
224
|
+
if not head: break
|
|
225
|
+
cur, words[k] = head, w[len(head):]; break
|
|
226
|
+
else: break
|
|
227
|
+
lines.append(cur)
|
|
228
|
+
return lines, ' '.join(words[k:])
|
|
229
|
+
|
|
230
|
+
def fill_text(text, values, path='-', mode='auto', default_policy='error', clear=(), allow=None):
|
|
231
|
+
slots, issues = scan_text(text, path, mode)
|
|
232
|
+
errs = [x for x in issues if x[0] == 'E']
|
|
233
|
+
if errs: raise SlotError('; '.join(f'line {l+1}: {m}' for _, l, _, m in errs))
|
|
234
|
+
lines = text.split('\n'); edits, inserts, used = [], [], set()
|
|
235
|
+
for s in slots:
|
|
236
|
+
key = f'@{s.index}'
|
|
237
|
+
target = key if key in values or key in clear else s.name
|
|
238
|
+
if target is None or (target not in values and target not in clear): continue
|
|
239
|
+
if allow is not None and target not in allow:
|
|
240
|
+
raise SlotError(f'{target}: not in the allowed slot list')
|
|
241
|
+
if target in clear:
|
|
242
|
+
used.add(target)
|
|
243
|
+
for x in [s] + s.conts:
|
|
244
|
+
h = x.header
|
|
245
|
+
edits.append((x.line, x.start, x.end, '<./' + render(h, '', x.width, x.pad) + '>'))
|
|
246
|
+
continue
|
|
247
|
+
used.add(target)
|
|
248
|
+
pol = s.policy or default_policy; what = s.name or key; unnamed = not s.header
|
|
249
|
+
v = clean(values[target], bool(s.conts))
|
|
250
|
+
if not s.conts:
|
|
251
|
+
raw = fit_value(v, s.capacity, pol, what, unnamed)
|
|
252
|
+
edits.append((s.line, s.start, s.end, '<./' + render(s.header, raw, s.width, s.pad) + '>'))
|
|
253
|
+
continue
|
|
254
|
+
rows = [s] + s.conts; caps = [r.capacity for r in rows]
|
|
255
|
+
got, left = wrap_words(v, caps); extra = []
|
|
256
|
+
if left:
|
|
257
|
+
if pol == 'grow':
|
|
258
|
+
while left:
|
|
259
|
+
more, left2 = wrap_words(left, [s.width - 1])
|
|
260
|
+
if left2 == left: raise SlotError(f'{what}: cannot wrap {left!r}')
|
|
261
|
+
extra.append(more[0]); left = left2
|
|
262
|
+
elif pol == 'ellipsis':
|
|
263
|
+
last = (got[-1] + ' ' + left).strip()
|
|
264
|
+
got[-1] = None; edits_last = cut_escaped(last, caps[-1], False, True)
|
|
265
|
+
elif pol == 'truncate': pass
|
|
266
|
+
else:
|
|
267
|
+
raise SlotError(f'{what}: block holds {sum(caps)} cells in {len(caps)} lines; '
|
|
268
|
+
f'{cells(left)} cells left over (overflow policy: error)')
|
|
269
|
+
for r, g in zip(rows, got):
|
|
270
|
+
raw = edits_last if g is None else escape(g, False)
|
|
271
|
+
edits.append((r.line, r.start, r.end, '<./' + render(r.header, raw, r.width, r.pad) + '>'))
|
|
272
|
+
if extra:
|
|
273
|
+
last = rows[-1]; ln = lines[last.line]
|
|
274
|
+
before, after = ln[:last.start], ln[last.end:]
|
|
275
|
+
lead = before if not before.strip(' \t#/;*-!<') else ' ' * display_width(before)
|
|
276
|
+
tail = after if after.strip() in CLOSERS or not after.strip() else ''
|
|
277
|
+
inserts.append((last.line, [lead + '<./' + render('|', escape(e, False), s.width, last.pad) + '>' + tail for e in extra]))
|
|
278
|
+
missing = [k for k in list(values) + list(clear) if k not in used]
|
|
279
|
+
if missing: raise SlotError(f'no slot named {", ".join(missing)}')
|
|
280
|
+
for li, st, en, new in sorted(edits, key=lambda e: (e[0], -e[1])):
|
|
281
|
+
lines[li] = lines[li][:st] + new + lines[li][en:]
|
|
282
|
+
for after, new in sorted(inserts, reverse=True):
|
|
283
|
+
lines[after + 1:after + 1] = new
|
|
284
|
+
return '\n'.join(lines)
|
dotslot/mcp.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Minimal MCP server (stdio, JSON-RPC 2.0, newline-delimited) exposing dotslot.
|
|
2
|
+
Run: python -m dotslot.mcp --root /path/to/repo
|
|
3
|
+
The only write path is `dotslot_fill`, which changes slot interiors and re-verifies the result."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import argparse, difflib, json, os, sys
|
|
6
|
+
from .core import scan_text, fill_text, SlotError
|
|
7
|
+
from .verify import verify
|
|
8
|
+
from . import __version__
|
|
9
|
+
|
|
10
|
+
TOOLS = [
|
|
11
|
+
dict(name='dotslot_scan', description='List the fill-in slots in a file (name, width, capacity, current value).',
|
|
12
|
+
inputSchema=dict(type='object', properties=dict(path=dict(type='string')), required=['path'])),
|
|
13
|
+
dict(name='dotslot_fill', description='Fill named or @N slots in a file. Only slot interiors change; '
|
|
14
|
+
'the result is verified fill-safe before it is written. Use dry_run to preview the diff.',
|
|
15
|
+
inputSchema=dict(type='object', properties=dict(
|
|
16
|
+
path=dict(type='string'), values=dict(type='object', additionalProperties=dict(type='string')),
|
|
17
|
+
overflow=dict(type='string', enum=['error', 'ellipsis', 'truncate', 'grow']),
|
|
18
|
+
dry_run=dict(type='boolean')), required=['path', 'values'])),
|
|
19
|
+
dict(name='dotslot_check', description='Prove a file differs from a base version only inside slots.',
|
|
20
|
+
inputSchema=dict(type='object', properties=dict(path=dict(type='string'), base_text=dict(type='string'),
|
|
21
|
+
allow=dict(type='array', items=dict(type='string'))), required=['path', 'base_text'])),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
class Server:
|
|
25
|
+
def __init__(self, root): self.root = os.path.realpath(root)
|
|
26
|
+
def _path(self, p):
|
|
27
|
+
full = os.path.realpath(os.path.join(self.root, p))
|
|
28
|
+
if os.path.commonpath([full, self.root]) != self.root: raise SlotError(f'{p}: outside the server root')
|
|
29
|
+
return full
|
|
30
|
+
def scan(self, path):
|
|
31
|
+
slots, issues = scan_text(open(self._path(path), encoding='utf-8').read(), path)
|
|
32
|
+
return dict(slots=[dict(index=s.index, name=s.name, line=s.line + 1, width=s.width, lines=1 + len(s.conts),
|
|
33
|
+
capacity=s.capacity + sum(c.capacity for c in s.conts), policy=s.policy,
|
|
34
|
+
value=(s.block_value() if s.conts else s.value)) for s in slots],
|
|
35
|
+
issues=[f'{sev} line {l+1}: {m}' for sev, l, _, m in issues])
|
|
36
|
+
def fill(self, path, values, overflow='error', dry_run=False):
|
|
37
|
+
full = self._path(path); old = open(full, encoding='utf-8').read()
|
|
38
|
+
new = fill_text(old, values, path, 'auto', overflow or 'error')
|
|
39
|
+
r = verify(old, new, path)
|
|
40
|
+
if not r['ok']: raise SlotError('refusing to write: ' + '; '.join(r['violations']))
|
|
41
|
+
diff = ''.join(difflib.unified_diff(old.splitlines(True), new.splitlines(True), path, path))
|
|
42
|
+
if not dry_run: open(full, 'w', encoding='utf-8').write(new)
|
|
43
|
+
return dict(written=not dry_run, changed=r['changed'], diff=diff)
|
|
44
|
+
def check(self, path, base_text, allow=None):
|
|
45
|
+
new = open(self._path(path), encoding='utf-8').read()
|
|
46
|
+
return verify(base_text, new, path, 'auto', set(allow) if allow else None)
|
|
47
|
+
|
|
48
|
+
def handle(self, msg):
|
|
49
|
+
m, mid, p = msg.get('method'), msg.get('id'), msg.get('params') or {}
|
|
50
|
+
if m == 'initialize':
|
|
51
|
+
res = dict(protocolVersion=p.get('protocolVersion', '2025-06-18'), capabilities=dict(tools={}),
|
|
52
|
+
serverInfo=dict(name='dotslot', version=__version__))
|
|
53
|
+
elif m == 'tools/list': res = dict(tools=TOOLS)
|
|
54
|
+
elif m == 'tools/call':
|
|
55
|
+
name, args = p.get('name'), p.get('arguments') or {}
|
|
56
|
+
fn = dict(dotslot_scan=self.scan, dotslot_fill=self.fill, dotslot_check=self.check).get(name)
|
|
57
|
+
try:
|
|
58
|
+
if not fn: raise SlotError(f'unknown tool {name}')
|
|
59
|
+
out, err = fn(**args), False
|
|
60
|
+
except (SlotError, OSError, TypeError) as e:
|
|
61
|
+
out, err = dict(error=str(e)), True
|
|
62
|
+
res = dict(content=[dict(type='text', text=json.dumps(out, ensure_ascii=False))], isError=err)
|
|
63
|
+
elif mid is None: return None # notification
|
|
64
|
+
elif m == 'ping': res = {}
|
|
65
|
+
else: return dict(jsonrpc='2.0', id=mid, error=dict(code=-32601, message=f'unknown method {m}'))
|
|
66
|
+
return None if mid is None else dict(jsonrpc='2.0', id=mid, result=res)
|
|
67
|
+
|
|
68
|
+
def main(argv=None):
|
|
69
|
+
ap = argparse.ArgumentParser(prog='dotslot-mcp'); ap.add_argument('--root', default='.')
|
|
70
|
+
a = ap.parse_args(argv); srv = Server(a.root)
|
|
71
|
+
for line in sys.stdin:
|
|
72
|
+
if not line.strip(): continue
|
|
73
|
+
out = srv.handle(json.loads(line))
|
|
74
|
+
if out is not None: sys.stdout.write(json.dumps(out, ensure_ascii=False) + '\n'); sys.stdout.flush()
|
|
75
|
+
|
|
76
|
+
if __name__ == '__main__':
|
|
77
|
+
main()
|
dotslot/verify.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Fill-safe verification: prove that only slot interiors changed between two texts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import difflib
|
|
4
|
+
from .core import scan_text
|
|
5
|
+
|
|
6
|
+
def _skeleton(text, slots):
|
|
7
|
+
lines = text.split('\n')
|
|
8
|
+
spans = {}
|
|
9
|
+
for s in slots:
|
|
10
|
+
key = s.name or f'@{s.index}'
|
|
11
|
+
spans.setdefault(s.line, []).append((s.start, s.end, f'⟦{key}⟧'))
|
|
12
|
+
for c in s.conts:
|
|
13
|
+
spans.setdefault(c.line, []).append((c.start, c.end, f'⟦{key}|⟧'))
|
|
14
|
+
out = []
|
|
15
|
+
for i, ln in enumerate(lines):
|
|
16
|
+
for st, en, tok in sorted(spans.get(i, []), reverse=True):
|
|
17
|
+
ln = ln[:st] + tok + ln[en:]
|
|
18
|
+
out.append(ln)
|
|
19
|
+
grow = {f'⟦{s.name}|⟧' for s in slots if s.policy == 'grow' and s.name}
|
|
20
|
+
col = [] # collapse repeated continuation lines of grow blocks (grow may add lines)
|
|
21
|
+
for ln in out:
|
|
22
|
+
if col and ln == col[-1] and any(g in ln for g in grow): continue
|
|
23
|
+
col.append(ln)
|
|
24
|
+
return col
|
|
25
|
+
|
|
26
|
+
def verify(base, new, path='-', mode='auto', allow=None):
|
|
27
|
+
bs, bi = scan_text(base, path, mode)
|
|
28
|
+
ns, ni = scan_text(new, path, mode)
|
|
29
|
+
v, changed = [], []
|
|
30
|
+
nlines = new.split('\n')
|
|
31
|
+
for sev, l, c, m in ni:
|
|
32
|
+
if sev == 'E': v.append(f'line {l+1}: {m}')
|
|
33
|
+
sb, sn = _skeleton(base, bs), _skeleton(new, ns)
|
|
34
|
+
if sb != sn:
|
|
35
|
+
d = [x for x in difflib.unified_diff(sb, sn, 'base', 'new', lineterm='', n=0)][2:]
|
|
36
|
+
v.append('text outside slots changed:\n ' + '\n '.join(d[:12]))
|
|
37
|
+
if len(bs) != len(ns):
|
|
38
|
+
v.append(f'slot count changed: {len(bs)} -> {len(ns)}')
|
|
39
|
+
for a, b in zip(bs, ns):
|
|
40
|
+
key = a.name or f'@{a.index}'
|
|
41
|
+
if (a.name, a.policy) != (b.name, b.policy):
|
|
42
|
+
v.append(f'{key}: header changed {a.header!r} -> {b.header!r}')
|
|
43
|
+
continue
|
|
44
|
+
if a.policy != 'grow':
|
|
45
|
+
if a.width != b.width:
|
|
46
|
+
v.append(f'{key}: width changed {a.width} -> {b.width} (closing > moved)')
|
|
47
|
+
if b.width < a.width and '>' in nlines[b.line][b.end:]:
|
|
48
|
+
v.append(f"{key}: hint: a raw '>' in the value closed the slot early; "
|
|
49
|
+
f"escape it as '\\>' or fill with `dotslot fill`, which escapes for you")
|
|
50
|
+
if len(a.conts) != len(b.conts):
|
|
51
|
+
v.append(f'{key}: block height changed {1+len(a.conts)} -> {1+len(b.conts)}')
|
|
52
|
+
va = a.block_value() if a.conts else (a.value or '')
|
|
53
|
+
vb = b.block_value() if b.conts else (b.value or '')
|
|
54
|
+
if va != vb:
|
|
55
|
+
changed.append(dict(slot=key, line=b.line + 1, before=va, after=vb))
|
|
56
|
+
if allow is not None and key not in allow:
|
|
57
|
+
v.append(f'{key}: changed but not in the allowed list')
|
|
58
|
+
return dict(ok=not v, changed=changed, violations=v, slots=len(ns))
|
dotslot/width.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Display-width model, vendored from /workspace/artifacts/slotfit/slotfit.py (experiment g).
|
|
2
|
+
wcwidth>=0.9 grapheme widths; tabs from the absolute column; ANSI = 0 cells."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import re, unicodedata
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from wcwidth import wcswidth, wcwidth
|
|
7
|
+
|
|
8
|
+
ANSI = re.compile(r'\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]')
|
|
9
|
+
ZWJ = '\u200d'
|
|
10
|
+
|
|
11
|
+
def _extends(c: str) -> bool:
|
|
12
|
+
"""True if c attaches to the previous cluster (zero-width joiners/marks/modifiers)."""
|
|
13
|
+
o = ord(c)
|
|
14
|
+
return (c == ZWJ or unicodedata.combining(c) or unicodedata.category(c) in ('Mn', 'Me')
|
|
15
|
+
or 0xFE00 <= o <= 0xFE0F or 0x1F3FB <= o <= 0x1F3FF or 0xE0020 <= o <= 0xE007F)
|
|
16
|
+
|
|
17
|
+
def clusters(s: str):
|
|
18
|
+
"""Yield display units: ANSI escapes, tabs, and grapheme-ish clusters."""
|
|
19
|
+
i, n = 0, len(s)
|
|
20
|
+
while i < n:
|
|
21
|
+
m = ANSI.match(s, i)
|
|
22
|
+
if m:
|
|
23
|
+
yield m.group(0); i = m.end(); continue
|
|
24
|
+
j = i + 1
|
|
25
|
+
while j < n and (_extends(s[j]) or s[j - 1] == ZWJ):
|
|
26
|
+
j += 1
|
|
27
|
+
# regional-indicator pair = one flag
|
|
28
|
+
if 0x1F1E6 <= ord(s[i]) <= 0x1F1FF and j < n and 0x1F1E6 <= ord(s[j]) <= 0x1F1FF:
|
|
29
|
+
j += 1
|
|
30
|
+
yield s[i:j]; i = j
|
|
31
|
+
|
|
32
|
+
def unit_width(u: str, col: int, tab: int) -> int:
|
|
33
|
+
if u == '\t':
|
|
34
|
+
return tab - (col % tab)
|
|
35
|
+
if u.startswith('\x1b'):
|
|
36
|
+
return 0
|
|
37
|
+
w = wcswidth(u)
|
|
38
|
+
if w < 0: # contains a control char: count printable parts only
|
|
39
|
+
w = sum(max(wcwidth(c), 0) for c in u)
|
|
40
|
+
return w
|
|
41
|
+
|
|
42
|
+
def legacy_ends(parts, tab=8):
|
|
43
|
+
"""Per-part end columns under per-codepoint wcwidth (pyte, older xterm, many
|
|
44
|
+
TUI libs): a ZWJ family, skin-tone emoji or flag counts 4-6 cells, VS16 adds 0."""
|
|
45
|
+
col, ends = 0, []
|
|
46
|
+
for text, _ in parts:
|
|
47
|
+
i = 0
|
|
48
|
+
while i < len(text):
|
|
49
|
+
m = ANSI.match(text, i)
|
|
50
|
+
if m:
|
|
51
|
+
i = m.end(); continue
|
|
52
|
+
c = text[i]
|
|
53
|
+
col += tab - (col % tab) if c == '\t' else max(wcwidth(c), 0)
|
|
54
|
+
i += 1
|
|
55
|
+
ends.append(col)
|
|
56
|
+
return ends
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Cell:
|
|
60
|
+
text: str; col: int; width: int; src: str # src: 'line' | slot name
|
|
61
|
+
|
|
62
|
+
def layout(parts, tab=8):
|
|
63
|
+
"""parts: [(text, src)] -> list[Cell] with absolute columns."""
|
|
64
|
+
cells, col = [], 0
|
|
65
|
+
for text, src in parts:
|
|
66
|
+
for u in clusters(text):
|
|
67
|
+
w = unit_width(u, col, tab)
|
|
68
|
+
cells.append(Cell(u, col, w, src)); col += w
|
|
69
|
+
return cells
|
|
70
|
+
|
|
71
|
+
def _end(_c, start, s, tab):
|
|
72
|
+
col = start
|
|
73
|
+
for u in clusters(s):
|
|
74
|
+
col += unit_width(u, col, tab)
|
|
75
|
+
return col - start
|
|
76
|
+
|
|
77
|
+
def display_width(s: str, start: int = 0, tab: int = 8) -> int:
|
|
78
|
+
"""Cells s occupies when it starts at column `start` (tabs depend on start)."""
|
|
79
|
+
return _end(None, start, s, tab)
|
|
80
|
+
|
|
81
|
+
def wrap_point(cells, target):
|
|
82
|
+
"""Index of first cell that does not fit in `target` columns, or None."""
|
|
83
|
+
for i, c in enumerate(cells):
|
|
84
|
+
if c.col + c.width > target:
|
|
85
|
+
return i
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def preview(cells, target):
|
|
89
|
+
k = wrap_point(cells, target)
|
|
90
|
+
if k is None:
|
|
91
|
+
return None
|
|
92
|
+
straddle = cells[k].width == 2 and cells[k].col == target - 1
|
|
93
|
+
vis = lambda cs: ''.join(' ' * c.width if c.text == '\t' else c.text for c in cs)
|
|
94
|
+
hard = (vis(cells[:k]), vis(cells[k:]))
|
|
95
|
+
# word wrap: last whitespace at or before k
|
|
96
|
+
b = next((i for i in range(k, -1, -1) if cells[i].text in (' ', '\t')), None)
|
|
97
|
+
if b is None or b == 0:
|
|
98
|
+
word = hard; moved = None; how = 'no space before the edge: falls back to a hard break'
|
|
99
|
+
else:
|
|
100
|
+
line1 = vis(cells[:b]).rstrip(' ')
|
|
101
|
+
rest = cells[b + 1:]
|
|
102
|
+
line2 = vis(rest)
|
|
103
|
+
moved = line2.split(' ')[0] if line2 else ''
|
|
104
|
+
word = (line1, line2); how = f'word "{moved}" moves to the next line'
|
|
105
|
+
return dict(target=target, hard_break_col=cells[k].col, hard=hard,
|
|
106
|
+
straddle=straddle, word=word, moved_word=moved, note=how)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cells(s: str) -> int:
|
|
110
|
+
"""Cells of s at column 0 ignoring tab-stop position (slot interiors forbid tabs)."""
|
|
111
|
+
return display_width(s, 0)
|
|
112
|
+
|
|
113
|
+
def cut(s: str, n: int):
|
|
114
|
+
"""Longest prefix of s that fits in n cells -> (prefix, its cells)."""
|
|
115
|
+
out, w = [], 0
|
|
116
|
+
for u in clusters(s):
|
|
117
|
+
uw = unit_width(u, w, 8)
|
|
118
|
+
if w + uw > n: break
|
|
119
|
+
out.append(u); w += uw
|
|
120
|
+
return ''.join(out), w
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dotslot
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Let agents fill the blanks in a file, and prove they touched nothing else.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/ao3575911/dotslot
|
|
7
|
+
Project-URL: Changelog, https://github.com/ao3575911/dotslot/blob/main/CHANGELOG.md
|
|
8
|
+
Keywords: templates,fixed-width,ai-agents,mcp,pre-commit,lint
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: wcwidth>=0.2.6
|
|
16
|
+
Provides-Extra: test
|
|
17
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
[](https://m8ven.ai/mcp/ao3575911-dotslot-ia87ds?s=readme)
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
dotslot: let agents fill the blanks, and prove they touched nothing else.
|
|
24
|
+
|
|
25
|
+
Owner: `<./owner:____________>` Status: `<./status~:____________>`
|
|
26
|
+
|
|
27
|
+
$ dotslot fill release.md --set owner=Adam --set status="ready for tag" -i
|
|
28
|
+
$ dotslot check release.md --git-base HEAD
|
|
29
|
+
release.md: fill-safe (2 slot(s) changed)
|
|
30
|
+
|
|
31
|
+
CLI · pre-commit · GitHub Action · MCP server (Claude Code, Cursor, aider)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
[](https://github.com/ao3575911/dotslot/actions/workflows/ci.yml) · MIT · v0.2.1 · [Spec](SPEC.md) · [Experiments](EXPERIMENTS.md) · [Changelog](CHANGELOG.md)
|
|
35
|
+
|
|
36
|
+
## What it is
|
|
37
|
+
|
|
38
|
+
Mark the parts of a file that may change with `<./________>`. An agent or script fills those blanks and nothing else. `dotslot check` proves the diff touched only the blanks, and that each blank kept its width, so columns and line wraps stay put. It works in docs, configs and code comments.
|
|
39
|
+
|
|
40
|
+
A file with slots is **fill-only**. That turns "don't touch the rest" into a rule a machine can check.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
Install from PyPI:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install dotslot
|
|
48
|
+
dotslot --help
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Requires Python 3.10+ and `wcwidth`.
|
|
52
|
+
|
|
53
|
+
## Use
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
dotslot new notes --width 26 --lines 3 --comment '# ' # generate a slot or block
|
|
57
|
+
dotslot scan FILE # list slots, widths, capacity
|
|
58
|
+
dotslot fill FILE --set name=value -i # fill in place, width kept
|
|
59
|
+
dotslot check FILE --git-base HEAD [--allow a,b] # prove only slots changed
|
|
60
|
+
dotslot repad FILE --base OLD -i # restore padding after a hand edit
|
|
61
|
+
dotslot fit FILE --targets 80,100,120 # where will lines wrap?
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Slot forms: `<./______>` (unnamed), `<./owner:____>` (named), `<./status~:____>` (ellipsis on overflow). In Markdown, put slots in backticks. Full grammar: [SPEC.md](SPEC.md).
|
|
65
|
+
|
|
66
|
+
## Integrations
|
|
67
|
+
|
|
68
|
+
| tool | files | what it does |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| MCP server | `dotslot-mcp --root .` | `dotslot_scan`, `dotslot_fill`, `dotslot_check` over stdio; fill verifies before writing; paths confined to root |
|
|
71
|
+
| Claude Code | [`integrations/claude-code/`](integrations/claude-code) | `.mcp.json`, a `PostToolUse` hook that exits 2 on a violation, and a skill |
|
|
72
|
+
| Cursor | [`integrations/cursor/.cursor/`](integrations/cursor/.cursor) | `mcp.json` and a fill-only rule |
|
|
73
|
+
| aider | [`integrations/aider/`](integrations/aider) | `lint-cmd: dotslot check --git-base HEAD` with auto-lint |
|
|
74
|
+
| any agent | [`integrations/AGENTS.md`](integrations/AGENTS.md) | snippet to paste into your `AGENTS.md` |
|
|
75
|
+
| pre-commit | [`.pre-commit-hooks.yaml`](.pre-commit-hooks.yaml) | `dotslot-check`, `dotslot-lint` |
|
|
76
|
+
| GitHub Action | [`action.yml`](action.yml) | checks PR files against the base SHA, writes a JSON report to the step summary |
|
|
77
|
+
|
|
78
|
+
pre-commit:
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
- repo: https://github.com/ao3575911/dotslot
|
|
82
|
+
rev: v0.2.1
|
|
83
|
+
hooks: [{ id: dotslot-check }]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
GitHub Action:
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
- uses: actions/checkout@v4
|
|
90
|
+
with: { fetch-depth: 0 }
|
|
91
|
+
- uses: ao3575911/dotslot@v0.2.1
|
|
92
|
+
with: { files: "docs/**/*.md config/*.yaml", allow: "status,owner" }
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Limits
|
|
96
|
+
|
|
97
|
+
dotslot is a check, not a sandbox. The comment finder is a heuristic until tree-sitter lands; formatters such as gofmt can move columns, so run dotslot after them; trailing whitespace counts as a change. See [CHANGELOG.md](CHANGELOG.md) for open items and [PLAN.md](PLAN.md) for the roadmap.
|
|
98
|
+
|
|
99
|
+
## Repository layout
|
|
100
|
+
|
|
101
|
+
- `dotslot/`: the v0.2 package (CLI, MCP server).
|
|
102
|
+
- `slot/`, `bin/slot`: the v0.1 reference, kept only so `experiments/run.sh` reproduces findings (a)–(g). Not installed.
|
|
103
|
+
- `tests/`: 45 tests (12 v0.1, 33 v0.2). Run `pip install -e ".[test]" && pytest -q`.
|
|
104
|
+
- `experiments/`: `run.sh` (a–g) and `run_v02.sh` (v02, h, i), with recorded output in `out/`.
|
|
105
|
+
|
|
106
|
+
## Licence
|
|
107
|
+
|
|
108
|
+
MIT. See [LICENSE](LICENSE). Releases are GPG-signed in CI and carry SHA256SUMS and build provenance; see [RELEASING.md](RELEASING.md).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
dotslot/__init__.py,sha256=2ZSUkgTgIxw8nSPXRv_Uopskmg11Q9xJPdU6l8eyqNA,115
|
|
2
|
+
dotslot/__main__.py,sha256=81Ek5TDGfhRLYb38ohIwEISYL8FAiiMV84Jhf6pukg8,50
|
|
3
|
+
dotslot/cli.py,sha256=AT7qZV1t4Bf42B1k_9ssAiK_wRJitjezaPGdVGjxnFs,11064
|
|
4
|
+
dotslot/core.py,sha256=ZXwNS6hhDQE_0CLxgGWhAqX2PPNSEIfU8NTcF0tgEPQ,13204
|
|
5
|
+
dotslot/mcp.py,sha256=da6G3j8AZv6oAC4mLDOemSdjs0x-XyW0EziN647N6Ps,4726
|
|
6
|
+
dotslot/verify.py,sha256=xlQUvFWjjY262dtp4xIDzddOR88r1Vrn-9JFCC0XERY,2769
|
|
7
|
+
dotslot/width.py,sha256=rVgqDJv_bVu4r6hCAqvfdnHWY3UC5cp5QTgm46za_3g,4367
|
|
8
|
+
dotslot-0.2.1.dist-info/licenses/LICENSE,sha256=42pO6DNxO2putX_18oSeOATK_LiOL9hvFHu_9LalqfQ,1066
|
|
9
|
+
dotslot-0.2.1.dist-info/METADATA,sha256=eIqjNx_psPF56r03VcE_FiXzW3FSXNHdM2kOhkGPxRc,4828
|
|
10
|
+
dotslot-0.2.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
dotslot-0.2.1.dist-info/entry_points.txt,sha256=WCOmATg-oO8C8Z7jIGHkNoP0Jc47K646UEn4KfNSxYM,76
|
|
12
|
+
dotslot-0.2.1.dist-info/top_level.txt,sha256=zVvev3A5wUo5G5qrsM7VcAUXP4q3Jrsa9G8n-7cKVto,8
|
|
13
|
+
dotslot-0.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ao3575911
|
|
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 @@
|
|
|
1
|
+
dotslot
|