Skip to content

Python API

Beyond the command line, clat is a small library. Import it to format LaTeX source from your own scripts, editors, or build tools.

from clat import texfmt, load_config

config = load_config()                 # nearest .clat.toml, or defaults
result = texfmt(open("main.tex").read(), filename="main.tex", config=config)

print(result.text)                     # the formatted source
for rule, n_hits in result.clangs:     # what was auto-fixed
    print(f"clang: {rule.name} ({n_hits})")
for rule, fname, line, msg in result.clunks:
    print(f"clunk: {fname}:{line}: {msg}")

If you omit config, texfmt uses the built-in defaults (threshold 5, every rule at its default weight). To run with a custom threshold without a file on disk, build the dict yourself:

config = {"threshold": 8, "weights": {"ellipsis": 9}}
result = texfmt(source, config=config)

Formatting

clat.texfmt

texfmt(text, filename='<input>', config=None)

Apply formatting rules according to config.

Returns a ClatResult with the formatted text and categorised issues.

For backwards compatibility, also accessible as (text, warnings) via the legacy property — but prefer ClatResult directly.

Source code in src/clat/rules.py
def texfmt(text, filename='<input>', config=None):
    """Apply formatting rules according to config.

    Returns a ClatResult with the formatted text and categorised issues.

    For backwards compatibility, also accessible as (text, warnings) via
    the legacy property — but prefer ClatResult directly.
    """
    if config is None:
        config = {'threshold': DEFAULT_THRESHOLD, 'weights': {}}

    threshold = config['threshold']
    result = ClatResult(text=text)

    # Sort rules by order for deterministic application
    sorted_rules = sorted(RULES, key=lambda r: r.order)

    for rule in sorted_rules:
        w = _effective_weight(rule, config)
        if w <= 0:
            continue

        if rule.fixable:
            original = result.text
            result.text = rule.fn(result.text)
            if result.text != original:
                orig_lines = original.split('\n')
                new_lines = result.text.split('\n')
                n_hits = (sum(1 for a, b in zip(orig_lines, new_lines)
                              if a != b)
                          + abs(len(orig_lines) - len(new_lines)))
                if w >= threshold:
                    result.clangs.append((rule, n_hits))
                else:
                    # Below threshold: still fix, but report as splat
                    result.splats.append((rule, filename, 0,
                                         f'{rule.name} (auto-fixed)'))
        else:
            issues = rule.fn(result.text, filename)
            for fname, line, msg in issues:
                if w >= threshold:
                    result.clunks.append((rule, fname, line, msg))
                else:
                    result.splats.append((rule, fname, line, msg))

    return result

clat.ClatResult dataclass

Result of running clat on a file.

Attributes:

Name Type Description
text str — the (possibly modified) source text
clangs list[tuple] — (rule, count) for auto-fixed rules above threshold
clunks list[tuple] — (rule, filename, line, msg) for unfixable issues above threshold
splats list[tuple] — (rule, filename, line, msg) for issues below threshold
Source code in src/clat/rules.py
@dataclass
class ClatResult:
    """Result of running clat on a file.

    Attributes
    ----------
    text : str          — the (possibly modified) source text
    clangs : list[tuple] — (rule, count) for auto-fixed rules above threshold
    clunks : list[tuple] — (rule, filename, line, msg) for unfixable issues above threshold
    splats : list[tuple] — (rule, filename, line, msg) for issues below threshold
    """
    text: str
    clangs: list = field(default_factory=list)
    clunks: list = field(default_factory=list)
    splats: list = field(default_factory=list)

Configuration

clat.load_config

load_config(path=None)

Load config from .clat.toml or fallback locations.

Returns a dict with 'threshold' (int) and 'weights' (dict[str, int]).

Source code in src/clat/rules.py
def load_config(path=None):
    """Load config from .clat.toml or fallback locations.

    Returns a dict with 'threshold' (int) and 'weights' (dict[str, int]).
    """
    try:
        import tomllib
    except ModuleNotFoundError:
        import tomli as tomllib
    from pathlib import Path

    search_paths = []
    if path:
        search_paths.append(Path(path))
    else:
        search_paths.append(Path.cwd() / '.clat.toml')
        config_home = Path.home() / '.config' / 'clat' / 'config.toml'
        search_paths.append(config_home)

    for p in search_paths:
        if p.is_file():
            with open(p, 'rb') as f:
                data = tomllib.load(f)
            return {
                'threshold': data.get('threshold', DEFAULT_THRESHOLD),
                'weights': data.get('weights', {}),
            }

    return {'threshold': DEFAULT_THRESHOLD, 'weights': {}}

clat.save_config

save_config(config, path)

Write config dict back to a .clat.toml file.

Source code in src/clat/rules.py
def save_config(config, path):
    """Write config dict back to a .clat.toml file."""
    from pathlib import Path
    # Rebuild from current state, respecting any weight overrides
    lines = [
        '# clat configuration',
        '# Adjust threshold and per-rule weights to taste.',
        '#',
        '# Categories are determined at runtime:',
        '#   clang:  weight >= threshold AND fixable     (auto-fixed)',
        '#   clunk:  weight >= threshold AND NOT fixable  (needs your attention)',
        '#   splat:  0 < weight < threshold               (advisory)',
        '#   off:    weight <= 0                          (disabled)',
        '',
        f'threshold = {config["threshold"]}',
        '',
        '[weights]',
    ]
    max_id = max(len(r.id) for r in RULES)
    for r in sorted(RULES, key=lambda r: r.order):
        w = _effective_weight(r, config)
        tag = 'fixable' if r.fixable else 'unfixable'
        lines.append(f'{r.id:<{max_id}} = {w:>2}  # {r.name} ({tag})')
    Path(path).write_text('\n'.join(lines) + '\n')
    return path

clat.generate_default_config

generate_default_config()

Return a .clat.toml string with all rules and their default weights.

Source code in src/clat/rules.py
def generate_default_config():
    """Return a .clat.toml string with all rules and their default weights."""
    lines = [
        '# clat configuration',
        '# Adjust threshold and per-rule weights to taste.',
        '#',
        '# Categories are determined at runtime:',
        '#   clang:  weight >= threshold AND fixable     (auto-fixed)',
        '#   clunk:  weight >= threshold AND NOT fixable  (needs your attention)',
        '#   splat:  0 < weight < threshold               (advisory)',
        '#   off:    weight <= 0                          (disabled)',
        '',
        f'threshold = {DEFAULT_THRESHOLD}',
        '',
        '[weights]',
    ]
    max_id = max(len(r.id) for r in RULES)
    for r in sorted(RULES, key=lambda r: r.order):
        tag = 'fixable' if r.fixable else 'unfixable'
        lines.append(f'{r.id:<{max_id}} = {r.weight:>2}  # {r.name} ({tag})')
    return '\n'.join(lines) + '\n'

The rule registry

clat.Rule dataclass

A single clat rule.

Attributes:

Name Type Description
id str — unique key, used in config overrides (e.g. 'labels_inline')
name str — human-readable description
fn callable — fix function f(text) -> text (fixable=True)

or warn function f(text, filename) -> [(file, line, msg)]

weight int — default severity 1–10; 0 disables the rule
fixable bool — True if clat can auto-fix this
order int — execution order (lower = earlier); fixes run before warns
Source code in src/clat/rules.py
@dataclass
class Rule:
    """A single clat rule.

    Attributes
    ----------
    id : str        — unique key, used in config overrides (e.g. 'labels_inline')
    name : str      — human-readable description
    fn : callable   — fix function  f(text) -> text           (fixable=True)
                       or warn function  f(text, filename) -> [(file, line, msg)]
    weight : int    — default severity 1–10; 0 disables the rule
    fixable : bool  — True if clat can auto-fix this
    order : int     — execution order (lower = earlier); fixes run before warns
    """
    num: int
    id: str
    name: str
    fn: Callable
    weight: int
    fixable: bool
    order: int

clat.RULES module-attribute

RULES = [Rule(1, 'labels_inline', 'Merge \\label onto the same line as \\section', rule1_labels_inline, weight=8, fixable=True, order=10), Rule(2, 'decorative_comments', 'Strip decorative comment separators (%%===, %%--- etc.)', rule7_strip_decorative_comments, weight=6, fixable=True, order=20), Rule(3, 'heading_spacing', 'Two blank lines before headings, none after', rule5_heading_spacing, weight=7, fixable=True, order=30), Rule(4, 'equation_separators', 'Insert % lines around display-math environments', rule2_equation_separators, weight=7, fixable=True, order=40), Rule(5, 'equation_punctuation', 'Add trailing comma or period to display equations', rule4_equation_punctuation, weight=6, fixable=True, order=50), Rule(6, 'float_indentation', 'Tab-indent content inside figure/table/list environments', rule6_figure_indentation, weight=5, fixable=True, order=60), Rule(7, 'one_sentence_per_line', 'Split sentences onto individual lines', rule3_one_sentence_per_line, weight=8, fixable=True, order=70), Rule(8, 'math_delimiters_inline', 'Replace \\(...\\) with $...$', rule8_math_delimiters_inline, weight=5, fixable=True, order=80), Rule(9, 'tilde_before_refs', 'Ensure non-breaking space before \\ref, \\cite etc.', rule9_tilde_before_refs, weight=7, fixable=True, order=90), Rule(10, 'number_unit_spacing', 'Normalise number-unit spacing (100\\,kN)', rule10_number_unit_spacing, weight=6, fixable=True, order=100), Rule(11, 'old_font_commands', 'Replace {\\bf text} with \\textbf{text} etc.', rule11_old_font_commands, weight=5, fixable=True, order=110), Rule(12, 'ellipsis', 'Replace ... with \\dots', rule12_ellipsis, weight=4, fixable=True, order=120), Rule(13, 'ordinal_suffixes', 'Convert superscript ordinals to plain text (1st, 2nd)', rule13_ordinal_suffixes, weight=8, fixable=True, order=130), Rule(14, 'long_file', 'Warn if file exceeds 2000 lines', warn_long_file, weight=3, fixable=False, order=200), Rule(15, 'hardcoded_refs', 'Detect "Figure 3" instead of \\cref{...}', warn_hardcoded_refs, weight=6, fixable=False, order=210), Rule(16, 'manual_sizing', 'Detect \\big, \\Big etc. (prefer \\left/\\right)', warn_manual_sizing, weight=3, fixable=False, order=220), Rule(17, 'float_after_heading', 'Detect float placed directly after a heading', warn_float_after_heading, weight=4, fixable=False, order=230), Rule(18, 'math_delimiters_display', 'Replace \\[...\\] with $$...$$', rule18_math_delimiters_display, weight=0, fixable=True, order=85)]

clat.DEFAULT_THRESHOLD module-attribute

DEFAULT_THRESHOLD = 5

Multi-file discovery

For multi-file documents, clat.cli.discover_tex_files expands a list of root files into the full, ordered, de-duplicated set of .tex files reachable through \input/\include-style commands — the same traversal the -r flag uses. See Multi-file documents.

from clat.cli import discover_tex_files

for path in discover_tex_files(["main.tex"]):
    print(path)

clat.cli.discover_tex_files

discover_tex_files(files)

Return files plus recursively discovered LaTeX inputs/includes.

Roots are visited in the order provided. Dependencies are depth-first, de-duplicated, and resolved relative to the file that references them. Missing .tex dependencies are included in the returned list so the normal formatter path reports them as missing.

Source code in src/clat/cli.py
def discover_tex_files(files):
    """Return files plus recursively discovered LaTeX inputs/includes.

    Roots are visited in the order provided. Dependencies are depth-first,
    de-duplicated, and resolved relative to the file that references them.
    Missing .tex dependencies are included in the returned list so the normal
    formatter path reports them as missing.
    """
    discovered = []
    seen = set()

    def visit(path):
        path = Path(path)
        if path.suffix == '':
            path = Path(f'{path}.tex')
        if not path.is_absolute():
            path = Path.cwd() / path
        path = path.resolve()

        if path in seen:
            return
        seen.add(path)
        discovered.append(path)

        try:
            text = path.read_text()
        except OSError:
            return

        for child in _iter_tex_inputs(text, path.parent):
            visit(child)

    for file in files:
        visit(file)

    return [_display_path(path) for path in discovered]