Metadata-Version: 2.4
Name: curtail
Version: 2026.7.8
Summary: Curtail (cut the tail) - reduce arbitrary text to a special string by cutting short.
Author-email: Stefan Hagen <stefan@hagen.link>
Maintainer-email: Stefan Hagen <stefan@hagen.link>
License-Expression: MIT
Project-URL: Documentation, https://codes.dilettant.life/docs/curtail
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"

# curtail

Curtail (cut the tail) - reduce arbitrary text to a special string by cutting short.

Feed it any string — unbalanced emphasis markers, mid-word cuts, mixed
code spans, raw user content — and it always returns a string that is both
short enough and well-formed inline markdown.

```python
from curtail import clip

clip('**bold text** and more _italic_', 20)
# → '**bold** (cont.)'

clip('`code with *stars*', 12, '…')
# → '`code with`…'
```

No runtime dependencies.  Pure Python ≥ 3.11.

## Install

```sh
pip install curtail
```

## Manual

The [man page](docs/curtail.1) provides the CLI reference (`man curtail` after placing the file on your `MANPATH`):

```sh
mkdir -p ~/.local/share/man/man1
cp docs/curtail.1 ~/.local/share/man/man1/
```

## Quickstart

Run `curtail` with no arguments for a usage summary.
For a dedicated feature walkthrough you can follow in minutes visit [quickstart](quickstart/README.md).
A step-by-step guided build of a document pipeline is provided in the [tutorial](tutorial/README.md).

## Library

### `clip(text, max_length, indicator=' (cont.)', *, word_boundary=True)`

Returns a string of at most `max_length` characters that is valid inline
markdown.

```python
from curtail import clip

# Truncation with default indicator
clip('**bold text** and more _italic_', 20)
# → '**bold** (cont.)'

# Custom indicator
clip('**bold text** and more _italic_', 16, '…')
# → '**bold text**…'

# Hard cut (no word-boundary snapping)
clip('hello world', 8, '...', word_boundary=False)
# → 'hello...'

# Unbalanced input is closed automatically
clip('**unfinished bold', 30)
# → '**unfinished bold**'
```

**Guarantees** — both hold unconditionally for every input:

- `len(result) ≤ max_length`
- `result` is well-formed inline markdown (all opened spans are closed)

### `patch(text, *, replace=None, stop_at=None)`

Pre-process text before passing it to `clip`.  Apply character
replacements, truncate at a sentinel, or both.

```python
from curtail import patch, clip

# Strip everything from the first newline
patch('first line\nsecond line', stop_at='\n')
# → 'first line'

# Normalise whitespace variants
patch('line\r\nend', replace={'\r': '', '\n': ' '})
# → 'line end'

# Escape pipe characters for a Markdown table cell, then truncate
cell = patch(raw, replace={'|': r'\|'}, stop_at='\n')
preview = clip(cell, 60, '…')
```

`stop_at` truncates before `replace` applies, so stop characters are never
transformed — they simply end the string.  Replacements inside backtick code
spans are skipped so that literal content is never altered.

### `clip_cell(text, max_length, indicator='…', *, word_boundary=True)`

Convenience wrapper for Markdown table cells.  Combines `patch` and
`clip` in one call: trims to the first line, escapes `|`, then truncates.

```python
from curtail import clip_cell

clip_cell('**bold** value with a|pipe\nand more', 40)
# → '**bold** value with a\|pipe…'
```

**Guarantees** — in addition to the two `clip` guarantees:

- Result contains no unescaped `|` characters.
- Result contains no `\n` characters.

### `code(text, connector='-', marker=chr(30), gremlins=GREMLINS, policy='lower')`

Derive a kebab-style identifier from arbitrary text.

```python
from curtail import code, DASH, RS, GREMLINS

code('Hello, World!')          # → 'hello-world'
code('foo-bar baz')            # → 'foo-bar-baz'  (connector preserved)
code('__init__')               # → 'init'
code('hello world', policy='upper')   # → 'HELLO-WORLD'
code('hello world', connector='_')    # → 'hello_world'
```

Connector characters already present in the input are preserved through a
sandwich transform: they are encoded as `marker` before gremlin replacement
and restored afterwards, so `'foo-bar'` and `'foo bar'` produce different identifiers.

Three module-level constants expose the defaults:

```python
from curtail import DASH, RS, GREMLINS

DASH     # '-'
RS       # chr(30)  — ASCII Record Separator (sandwich-transform marker)
GREMLINS # ' .,;?!_()[]{}<>\\/$:"\'`´'
```

---

## CLI

Calling `curtail` with no arguments prints the help screen and exits 0.

Unique prefix matching is supported for all subcommands (`cl` → `clip`, `ce` → `cell`, etc.).

```
curtail [-V] clip   -n N [-i STR] [--hard-cut] [--stop-at CHARS] [--replace FROM TO] [text]
curtail [-V] cell   -n N [-i STR] [--hard-cut] [text]
curtail [-V] code   [--connector STR] [--gremlins STR] [--policy METHOD] [--marker STR] [text]
curtail [-V] patch  [--stop-at CHARS] [--replace FROM TO] [text]
curtail [-V] version
```

When `text` is omitted the input is read from **stdin**.

```sh
# Clip: basic truncation
echo "**bold text** and more _italic_" | curtail clip -n 20
# → **bold** (cont.)

# Cell: table-cell pipeline (pipes escaped, newlines dropped)
curtail cell -n 60 -i '…' "$(cat cell.txt)"

# Clip with pre-processing: stop at newline, escape pipe
curtail clip -n 60 -i '…' --stop-at $'\n' --replace '|' '\|' "$(cat cell.txt)"

# Code: identifier from a heading
curtail code 'Hello, World!'
# → hello-world

# Code with custom connector and upper-case policy
curtail code --connector '_' --policy upper 'hello world'
# → HELLO_WORLD

# Patch: pre-process without truncating
curtail patch --stop-at $'\n' 'first line\nsecond line'
# → first line
```

| Subcommand | Flag              | Default      | Meaning                                                 |
|:-----------|:------------------|:-------------|:--------------------------------------------------------|
| `clip`     | `-n N`            | *(required)* | Maximum output length                                   |
| `clip`     | `-i STR`          | `' (cont.)'` | Continuation indicator                                  |
| `clip`     | `--hard-cut`      | off          | Character-level cut instead of word boundary            |
| `clip`     | `--stop-at CHARS` | —            | Truncate at first occurrence of any of these characters |
| `clip`     | `--replace FROM TO` | —          | Replace character FROM with string TO (repeatable)      |
| `cell`     | `-n N`            | *(required)* | Maximum output length                                   |
| `cell`     | `-i STR`          | `'…'`        | Continuation indicator                                  |
| `cell`     | `--hard-cut`      | off          | Character-level cut instead of word boundary            |
| `code`     | `--connector STR` | `'-'`        | Separator between identifier parts                      |
| `code`     | `--gremlins STR`  | GREMLINS     | Characters replaced by the connector                    |
| `code`     | `--policy METHOD` | `lower`      | str method applied to the final identifier              |
| `code`     | `--marker STR`    | chr(30)      | Sandwich-transform placeholder (rarely needed)          |
| `patch`    | `--stop-at CHARS` | —            | Truncate at first occurrence of any of these characters |
| `patch`    | `--replace FROM TO` | —          | Replace character FROM with string TO (repeatable)      |
| all        | `-V`, `--version` | —            | Print version and exit                                  |

## Tracked inline constructs

| Construct | Opener             | Closer                    |
|:----------|:-------------------|:--------------------------|
| Code span | `` ` ``, ` `` `, … | matching run of backticks |
| Emphasis  | `*` or `_`         | matching `*` or `_`       |
| Strong    | `**` or `__`       | matching `**` or `__`     |

Underscore emphasis follows CommonMark flanking rules: `_` surrounded by
alphanumeric characters on both sides (intra-word) is treated as literal text.

Links, images, block-level elements, HTML tags, and entity references are
intentionally left unchanged — they either render safely as literal text
(unclosed `[`) or are the caller's responsibility.

## Development

```sh
# Tests (238 cases including Hypothesis property tests)
make test

# Branch coverage (100 % on both implementation modules)
make coverage

# Format, lint, type-check, security scan
make quality

# Coverage-guided fuzzing (requires python-afl and AFL++)
make fuzz-scan fuzz_time=300
make corpus-update          # merge → minimise → promote findings
```

## Design and requirements

| Document                            | Identifier  | File                                            |
|:------------------------------------|:------------|:------------------------------------------------|
| Software Requirements Specification | CRT-SRS-001 | [requirements/srs/](requirements/srs/README.md) |
| Software Design Description         | CRT-SDD-001 | [design/sdd/](design/sdd/README.md)             |

Both documents follow the MIL-STD-498 DID structure and are rendered into
the documentation site alongside the quickstart and tutorial.

## Bug Tracker

Any feature requests or bug reports shall go to the [todos of curtail](https://todo.sr.ht/~sthagen/curtail).

## Primary Source repository

The main source of `curtail` is on a mountain in Central Switzerland under
configuration control ([fossil](https://fossil-scm.org/)).

## Contributions

If you like to share small changes under the repositories license please kindly
do so by sending a patchset.
You can send such a patchset per email using [git send-email](https://git-send-email.io).

## Support

Please kindly submit issues at https://todo.sr.ht/~sthagen/curtail or write plain
text email to <~sthagen/curtail@lists.sr.ht> to support.
Thanks.

## Changes

See `docs/changes.md` for the release history.

## Coverage

The test suite maintains 100% branch coverage on both implementation modules.
The HTML report (if generated) is in `site/coverage/`.

## SBOM

Runtime dependency information is published in `docs/sbom/` in SPDX 2.3 (JSON)
and CycloneDX 1.5 (JSON) formats.
See `docs/sbom/README.md` for the component inventory and validation guide.
