Metadata-Version: 2.4
Name: apparmor-language-server
Version: 0.7.0
Summary: Language Server Protocol server for AppArmor profiles
License: GPL-3.0+
Project-URL: Homepage, https://gitlab.com/apparmor/apparmor-language-server
Project-URL: Repository, https://gitlab.com/apparmor/apparmor-language-server
Project-URL: Bug Tracker, https://gitlab.com/apparmor/apparmor-language-server/-/issues
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Text Editors
Classifier: Topic :: System :: Systems Administration
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pygls>=2.1
Requires-Dist: lsprotocol>=2024.0
Provides-Extra: dev
Requires-Dist: bandit; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-asyncio; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Requires-Dist: pytest-lsp; extra == "dev"
Requires-Dist: ruff; extra == "dev"
Requires-Dist: ty; extra == "dev"
Dynamic: license-file

# AppArmor Language Server (`apparmor-language-server`)

[![apparmor-language-server](https://snapcraft.io/apparmor-language-server/badge.svg)](https://snapcraft.io/apparmor-language-server)
[![PyPI - Version](https://img.shields.io/pypi/v/apparmor-language-server)](https://pypi.org/project/apparmor-language-server)
[![CI](https://gitlab.com/apparmor/apparmor-language-server/badges/main/pipeline.svg)](https://gitlab.com/apparmor/apparmor-language-server/.gitlab-ci.yml)
[![Coverage](https://gitlab.com/apparmor/apparmor-language-server/badges/main/coverage.svg)](https://gitlab.com/apparmor/apparmor-language-server/-/graphs/main/charts)
[![License: GPL 3.0](https://img.shields.io/badge/License-GPLv3+-blue.svg)](https://www.gnu.org/licenses/gpl-3.0+)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)

A full-featured [Language Server Protocol](https://microsoft.github.io/language-server-protocol/)
server for editing **AppArmor profiles**, written in Python using
[pygls](https://github.com/openlawlibrary/pygls).

---

## Features

| Feature | Details |
|---|---|
| **Completion** | Rule keywords with snippets, all Linux capabilities, network families/types, signal names, ptrace/mount/dbus/unix permissions, file permission strings (`r`, `rw`, `rix`, `rPx`, …), `@{variable}` names, `#include` abstraction paths, live filesystem path completion |
| **Hover** | Rich Markdown docs for every keyword, capability, permission char, network family, profile flag and variable |
| **Goto Definition** | Jump from `#include <…>` or `include <…>` to the target file; jump to profile definitions by name; follow exec transitions (`px`, `cx`, `Px`, …) and `change_profile` rules to the target profile by name or attachment path |
| **Call Hierarchy** | Incoming calls: which profiles exec-transition or `change_profile` into a given profile; outgoing calls: which profiles a given profile transitions to. Triggered from a profile name or from an exec-transition target token |
| **Document Symbols** | Full outline: all profiles, hats, capabilities, file rules, includes and variables |
| **Workspace Symbols** | Search profiles across all open documents |
| **Diagnostics / Linting** | Unknown capabilities, network families/types, signal permissions/names, ptrace permissions, dbus permissions, unix socket types/permissions, mqueue types/permissions, io_uring permissions, userns permissions, mount options, rlimit resources/values; dangerous unconfined exec (`ux`/`Ux`/`pux`/`PUx`/`cux`/`CUx`); conflicting profile mode flags; invalid `flags=(error=…)` errno values; empty profiles; duplicate and conflicting capabilities; duplicate permissions within a single signal/ptrace/dbus/unix/mqueue/io_uring/userns rule; duplicate signal names within a `set=(…)` list; conflicting `allow`+`deny`; undefined variables and bool variables; unused variables; unused preamble includes; missing `include`/`abi` targets; missing `abi` declaration; missing `tunables/global` include; unclosed profiles; mutually exclusive file permissions (`w`+`a`); multiple exec transition modes; exec target without exec transition; exec transition with `deny`; bare `x` without `deny`; relative `alias` paths; `pivot_root` paths without trailing `/`; `network netlink` restricted to `dgram`/`raw`; profile name does not match document filename; missing `include if exists <local/…>` at end of profile; also surfaces errors from `apparmor_parser` itself when available (skipped automatically for abstraction and tunables files) |
| **Formatting** | Normalise indentation, remove trailing whitespace, sort capabilities alphabetically, sort parenthesised lists, ensure trailing commas on all rules, normalise `#include` → `include`, collapse multiple blank lines, align consecutive file rules so the path/permission column lines up, wrap long rules with a continuation indent, format dbus rules clause-by-clause, preserve inline comments, preserve or normalise file rule style (implicit / explicit / node-local) |
| **Range Formatting** | Format a selected region only |
| **References** | Find all references to the identifier or variable under the cursor across all open documents (excludes path components and comments) |
| **Document Links** | `include` and `abi` paths rendered as inline hyperlinks; clicking navigates to the target file |
| **Document Highlight** | Highlight all occurrences of the word under the cursor |
| **Semantic Tokens** | Full syntax highlighting pushed to the editor: rule keywords, qualifiers (`allow`/`deny`/`audit`/`priority=N`), identifiers (profile names, capability names, network families, …), paths, permission strings, `key=value` parameters, operators (`->`, `+=`, …), variable references (`@{VAR}`, `${BOOL}`), comments, and numbers — enables theme-aware colouring without relying on TextMate grammar files |
| **Rename** | Rename a variable (`@{VAR}`) in place across all open documents; `prepareRename` validates the target before the operation and prevents renaming of non-variable tokens |
| **Folding Range** | Collapse profile, hat, `if`, and qualifier blocks in the editor gutter |
| **Selection Range** | Expand selection from rule → enclosing block → profile on each keypress |
| **Code Actions** | Quick fixes for flagged diagnostics — see the [Code actions reference](#code-actions-reference) below |

---

## Contributing and Issues

We welcome contributions! Please feel free to open a [Merge Request](https://gitlab.com/apparmor/apparmor-language-server/-/merge_requests).

If you find a bug or have a feature request, please check the [Issue tracker](https://gitlab.com/apparmor/apparmor-language-server/-/issues) to see if it has already been reported. If not, feel free to open a new issue.

---

## Installation

### From snap

```bash
sudo snap install apparmor-language-server
```

The snap provides the `apparmor-language-server`, `apparmor-lint`, and
`apparmor-format` commands. Access to `/etc/apparmor.d` for resolving
`include` directives and indexing system profiles is granted automatically.

[![Get it from the Snap Store](https://snapcraft.io/en/light/install.svg)](https://snapcraft.io/apparmor-language-server)

### From PyPI

```bash
pip install apparmor-language-server
```

### From source

```bash
git clone https://gitlab.com/apparmor/apparmor-language-server
cd apparmor-language-server
pip install .
```

Or install dependencies directly without building a package:

```bash
pip install pygls lsprotocol
python -m apparmor_language_server          # stdio mode
python -m apparmor_language_server --tcp    # TCP mode on 127.0.0.1:2087
```

---

## Running the server

### stdio (default — used by most editors)

```bash
apparmor-language-server
# or
python -m apparmor_language_server
```

### TCP (useful for debugging)

```bash
apparmor-language-server --tcp --host 127.0.0.1 --port 2087
```

---

## Standalone linter (`apparmor-lint`)

The package also ships a `apparmor-lint` command-line tool that runs the
**same parser and diagnostic checks** as the language server — useful in CI,
pre-commit hooks, or when you just want a quick check from the shell without
firing up an editor.

```bash
# Lint one or more files
apparmor-lint /etc/apparmor.d/usr.bin.foo
apparmor-lint profile another-profile

# Read from stdin
cat profile | apparmor-lint -

# Skip the external apparmor_parser cross-check
apparmor-lint --no-parser profile

# Machine-readable output for CI
apparmor-lint --format json profile
```

### Output format

The default `pretty` format is GCC-compatible so editors and tools that
already parse `cc(1)` output (Vim quickfix, Emacs `compile`, `grep -nH`, …)
work out of the box:

```
profile:3:3: error: Unknown capability 'bad_cap_xyz'. … [unknown-capability] (apparmor-language-server)
profile:5:3: error: Network rule: netlink may only specify type 'dgram' or 'raw' (got 'stream'). [netlink-type-restricted] (apparmor-language-server)
```

`--format json` emits an array of records, each with `path`, `uri`,
`severity`, `message`, `code`, `source`, `line`, `column`, `end_line`,
and `end_column` — handy for piping into `jq` or aggregating across files.

### Options

| Flag | Meaning |
|---|---|
| `paths…` | One or more files to lint, or `-` to read from stdin |
| `--no-parser` | Skip the external `apparmor_parser -Q -K` cross-check |
| `--apparmor-parser PATH` | Use a specific `apparmor_parser` binary (default: `$PATH` lookup) |
| `-I DIR`, `--include-path DIR` | Extra directory to search for `include`/`abi` targets (repeatable) |
| `--format {pretty,json}` | Output format (default: `pretty`) |
| `-q`, `--quiet` | Only show error-severity diagnostics |

### Exit codes

| Code | Meaning |
|---|---|
| `0` | Clean — no error-severity diagnostics (warnings, info, hints permitted) |
| `1` | At least one error-severity diagnostic was emitted |
| `2` | The CLI itself could not run (file missing, argument is a directory, etc.) |

### Library API

`apparmor_language_server.lint` also exposes the linter as a Python API for
embedding in other tools:

```python
from apparmor_language_server.lint import lint_file, lint_text

# Returns dict[str, list[lsprotocol.types.Diagnostic]] keyed by URI.
diags = lint_file(Path("profile"), run_apparmor_parser=False)
diags = lint_text("profile x { /foo r, }\n")
```

---

## Standalone formatter (`apparmor-format`)

The package also ships an `apparmor-format` command-line formatter that uses
the **same formatter and renderer behavior** as the language server.

```bash
# Print formatted output to stdout
apparmor-format profile

# Rewrite a file in place
apparmor-format --in-place profile

# Check whether files would change
apparmor-format --check profile another-profile

# Show a unified diff of what would change (useful in CI and pre-commit hooks)
apparmor-format --diff profile another-profile

# Read from stdin
cat profile | apparmor-format -
```

### Modes

| Flag | Meaning |
|---|---|
| *(default)* | Write formatted output to stdout |
| `-i`, `--in-place` | Rewrite files in place |
| `--check` | Exit non-zero if any input would be reformatted, printing a `would reformat:` line per file |
| `--diff` | Print a unified diff of formatting changes to stdout; exit non-zero if any input would change |

### Formatter options

These flags cover the formatter-specific behaviour; include resolution uses the
parser defaults and missing includes do not prevent formatting.

| Flag | Meaning |
|---|---|
| `--indent-width N` | Spaces per indent level (default: `2`) |
| `--max-line-length N` | Wrap rules longer than `N` columns; `0` disables wrapping |
| `--file-rule-style {node-local,implicit,explicit}` | Preserve parsed file rule syntax, or force implicit / explicit rendering |

### Exit codes

| Code | Meaning |
|---|---|
| `0` | Formatting succeeded, or `--check`/`--diff` found no changes |
| `1` | `--check` or `--diff` found one or more inputs that would change |
| `2` | The CLI itself could not run (bad arguments, missing files, etc.) |

---

## Server configuration

### Environment variables

| Variable | Default | Effect |
|---|---|---|
| `APPARMOR_LSP_LOG_LEVEL` | `INFO` | Log verbosity: `DEBUG`, `INFO`, `WARNING`, `ERROR` |

### LSP workspace settings

These are passed to the server via the standard LSP
`workspace/didChangeConfiguration` notification, nested under the
`apparmor` key.  How you set them depends on your editor (see the
examples below each editor section).

| Setting | Type | Default | Effect |
|---|---|---|---|
| `apparmor.diagnostics.enable` | boolean | `true` | Enable or disable all diagnostic (linting) checks |
| `apparmor.baseDir` | string | `"/etc/apparmor.d"` | Base directory for AppArmor profiles. Passed as `--base` to `apparmor_parser`. Also used as the default value for `apparmor.includeSearchPaths` when that setting is not configured. Defaults to `/var/lib/snapd/hostfs/etc/apparmor.d` when running as a snap. |
| `apparmor.parserConfigFile` | string | `""` | Path to the `apparmor_parser` configuration file, passed as `--config-file`. Leave empty to auto-detect: under snap confinement the host `/etc/apparmor/parser.conf` is used automatically; outside snap no `--config-file` is passed. |
| `apparmor.includeSearchPaths` | string[] | `[]` | Extra directories to search when resolving `include` and `abi` paths, prepended ahead of `apparmor.baseDir`. When empty, `apparmor.baseDir` is used as the sole search directory. |
| `apparmor.profilesSubdirs` | string[] | `["apparmor.d", "profiles/apparmor.d"]` | Subdirectories of the workspace root to index for workspace symbols; each entry is resolved relative to the workspace root. Set to `[""]` or `["."]` to index the whole workspace. Multiple entries are all indexed. |
| `apparmor.apparmorParserPath` | string | `""` | Path to the `apparmor_parser` binary. Leave empty to auto-detect from `$PATH`. Set to a specific path (e.g. `/usr/sbin/apparmor_parser`) to pin a particular version. When the binary is found, the server runs `apparmor_parser -Q -K` against each saved profile file and surfaces any errors as diagnostics. Files with no top-level profiles (abstractions, tunables, ABI files) are skipped automatically. |
| `apparmor.formatting.maxLineLength` | integer | `100` | Wrap rules longer than this many columns with a continuation indent; `0` disables wrapping. |
| `apparmor.formatting.fileRuleStyle` | string | `"node-local"` | How to render file rules: `"node-local"` preserves the parsed style, `"implicit"` forces `path perms,` form, `"explicit"` forces `file perms path,` form. |
| `apparmor.formatting.sortLists` | boolean | `true` | Sort comma- or space-separated lists into alphabetical order: capability names, profile flags, and parenthesised permission/signal sets (e.g. `(receive send)`). |
| `apparmor.formatting.normalizeInclude` | boolean | `true` | Rewrite `#include` directives as `include` (the preferred no-hash form). |
| `apparmor.formatting.maxBlankLines` | integer | `1` | Collapse runs of consecutive blank lines to at most this many. `0` strips all blank lines. `-1` disables collapsing (blank lines are preserved as-is). |

---

## Editor configuration

### Neovim (with `nvim-lspconfig`)

```lua
-- In your init.lua or a plugin file
local lspconfig = require('lspconfig')
local configs   = require('lspconfig.configs')

-- Register the server if it is not already known
if not configs.apparmor_language_server then
  configs.apparmor_language_server = {
    default_config = {
      cmd         = { 'apparmor-language-server' },  -- or { 'python', '-m', 'apparmor_language_server' }
      filetypes   = { 'apparmor' },
      root_dir    = lspconfig.util.root_pattern('.git', '/etc/apparmor.d'),
      single_file_support = true,
      settings    = {},
    },
  }
end

lspconfig.apparmor_language_server.setup({
  on_attach = function(client, bufnr)
    -- Enable format-on-save
    vim.api.nvim_create_autocmd('BufWritePre', {
      buffer = bufnr,
      callback = function()
        vim.lsp.buf.format({ async = false })
      end,
    })
  end,
})

-- Tell Neovim about the AppArmor filetype
vim.filetype.add({
  pattern = {
    ['/etc/apparmor.d/.*']     = 'apparmor',
    ['/etc/apparmor/.*%.conf'] = 'apparmor',
    ['.*%.apparmor']           = 'apparmor',
  },
})
```

### VS Code

A VS Code extension is included in `editors/vscode/`. To build and install
it locally:

```bash
cd editors/vscode
npm install
npm run compile
npx vsce package --no-dependencies   # produces apparmor-language-server-*.vsix
code --install-extension apparmor-language-server-*.vsix
```

The extension locates the server automatically in this order:

1. `apparmor.serverPath` setting — explicit path to the `apparmor-language-server` executable.
2. `apparmor-language-server` on `PATH` — covers system installs, `pip install --user`, and activated virtualenvs.
3. The active Python interpreter from the [Python extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python), if installed.
4. `apparmor.pythonPath` setting, or `python3` as a last resort.

Files matching `**/apparmor.d/**` are recognised automatically. Use the
`AppArmor: Restart Language Server` command palette entry to restart the
server after installing or upgrading the Python package.

### Emacs (with `eglot`)

```elisp
(add-to-list 'auto-mode-alist '("/etc/apparmor\\.d/.*" . apparmor-mode))
(with-eval-after-load 'eglot
  (add-to-list 'eglot-server-programs
               '(apparmor-mode . ("apparmor-language-server"))))
```

### Emacs (with `lsp-mode`)

```elisp
(with-eval-after-load 'lsp-mode
  (lsp-register-client
   (make-lsp-client
    :new-connection (lsp-stdio-connection '("apparmor-language-server"))
    :major-modes '(apparmor-mode)
    :server-id 'apparmor-language-server)))
```

### Helix

In `~/.config/helix/languages.toml`:

```toml
[[language]]
name              = "apparmor"
scope             = "source.apparmor"
file-types        = ["apparmor", { glob = "/etc/apparmor.d/**" }]
language-servers  = ["apparmor-language-server"]
comment-token     = "#"
indent            = { tab-width = 2, unit = "  " }

[language-server.apparmor-language-server]
command = "apparmor-language-server"
```

### Sublime Text (with `LSP` package)

In `LSP.sublime-settings`:

```json
{
  "clients": {
    "apparmor-language-server": {
      "enabled": true,
      "command": ["apparmor-language-server"],
      "selector": "source.apparmor"
    }
  }
}
```

---

## Formatting options

The formatter respects the editor's `tabSize` setting (passed via the LSP
`DocumentFormattingParams`).  All other options are controlled via the
`apparmor.formatting.*` server configuration keys described in the server
configuration table above; the `apparmor-format` CLI exposes the same
options as command-line flags.

---

## Diagnostics reference

| Code | Severity | Meaning |
|---|---|---|
| `parse-error` | Error | Syntax error detected by parser |
| `unknown-capability` | Error | Capability not in `man 7 capabilities` list |
| `unknown-flag` | Error | Unrecognised profile flag in `flags=(…)` |
| `unknown-network-qualifier` | Warning | Unknown network family or socket type |
| `unknown-keyword` | Warning | Unrecognised rule keyword |
| `dangerous-exec` | Warning | `ux`/`Ux`/`pux`/`PUx`/`cux`/`CUx` allows unconfined exec |
| `empty-profile` | Warning | Profile body has no rules |
| `duplicate-capability` | Warning | Capability listed more than once within the same rule, or already declared in an earlier rule in the same profile |
| `duplicate-permission` | Warning | Permission token listed more than once in a single signal/ptrace/dbus/unix/mqueue/io_uring/userns rule |
| `conflicting-capability` | Warning | Same capability both allowed and denied |
| `undefined-variable` | Warning | `@{VAR}` used but never defined |
| `undefined-bool-variable` | Warning | `${BOOL_VAR}` referenced in an `if` condition but never defined |
| `unused-variable` | Warning | `@{VAR}` or `${BOOL_VAR}` defined in the global scope but never referenced anywhere in the document (only checked in profile files, not abstractions) |
| `unused-include` | Warning | Preamble include whose contributed variables are all unreferenced in the document, or that contributes no preamble-valid content at all (e.g. a rule abstraction mistakenly placed outside a profile body); only checked in profile files, not abstractions |
| `missing-tunables-global` | Warning | Profile file does not include `<tunables/global>` in its preamble |
| `missing-abi-declaration` | Warning | Profile file does not have an `abi` declaration in its preamble |
| `missing-include` | Warning | Include target not found on disk |
| `missing-abi` | Warning | ABI target not found on disk |
| `unknown-signal-permission` | Warning | Invalid permission in `signal` rule |
| `unknown-signal-name` | Warning | Unknown signal name in `signal set=(…)` |
| `duplicate-signal-name` | Warning | Signal name listed more than once in a `set=(…)` list |
| `unknown-ptrace-permission` | Warning | Invalid permission in `ptrace` rule |
| `perm-conflict-write-append` | Error | `w` and `a` are mutually exclusive in a file rule |
| `multiple-exec-modes` | Error | More than one exec transition mode (e.g. `ix`, `px`, `cx`) in a single file rule |
| `exec-target-without-transition` | Error | `->` target present but neither an exec transition mode (e.g. `px`, `cx`, `ix`) nor `l` (link) permission gives it a meaning |
| `deny-with-exec-transition` | Error | Exec transition mode (e.g. `ix`, `px`) used with the `deny` qualifier — use `deny x` instead |
| `bare-x-without-deny` | Error | Bare `x` permission used without the `deny` qualifier — use an exec transition mode (`ix`, `px`, `cx`, …) |
| `file-rule-subsumed` | Warning | File rule whose path and permissions are entirely covered by another rule in the same profile body, or by a rule from a transitively included abstraction |
| `rule-subsumed` | Warning | Capability, network, signal, ptrace, userns, io_uring, mqueue, unix, dbus, mount, umount, remount, pivot_root, change_profile, or change_hat rule entirely covered by a broader sibling rule or a rule from a transitively included abstraction; also flags any non-deny rule covered by an `all,` rule |
| `allow-deny-conflict` | Error | `allow` and `deny` qualifiers used together on the same rule |
| `conflicting-profile-modes` | Error | More than one profile mode flag (`enforce`/`complain`/`kill`/`default_allow`/`unconfined`/`prompt`) set together |
| `invalid-error-flag-value` | Warning | `flags=(error=…)` value is not a valid `E…` errno name |
| `alias-relative-path` | Warning | `alias` source/target is not an absolute path |
| `unknown-mount-option` | Warning | Mount option not in the documented `mount(8)` flag list |
| `unknown-rlimit-resource` | Error | Resource name not recognised by `setrlimit(2)` |
| `invalid-rlimit-value` | Warning | rlimit value/unit doesn't match the resource family (size, time, integer, nice range -20..19) |
| `unknown-dbus-permission` | Warning | Invalid permission in `dbus` rule |
| `dbus-bind-in-message-rule` | Error | `bind` permission used in a dbus message rule (path/interface/member/peer) |
| `dbus-send-recv-in-service-rule` | Error | `send`/`receive` used in a dbus service rule (`name=`) |
| `dbus-eavesdrop-with-conds` | Error | `eavesdrop` used with conditionals other than `bus=` |
| `unknown-unix-permission` | Warning | Invalid permission in `unix` socket rule |
| `unknown-unix-type` | Warning | `type=` not in `stream`/`dgram`/`seqpacket` |
| `unknown-mqueue-permission` | Warning | Invalid permission in `mqueue` rule |
| `unknown-mqueue-type` | Warning | `type=` not in `posix`/`sysv` |
| `mqueue-posix-name-shape` | Error | POSIX mqueue name must start with `/` |
| `mqueue-sysv-name-shape` | Error | SysV mqueue name must be a positive integer |
| `unknown-io-uring-permission` | Warning | `io_uring` permission not in `sqpoll`/`override_creds`/`cmd` |
| `unknown-userns-permission` | Warning | `userns` permission other than `create` |
| `netlink-type-restricted` | Error | `network netlink` may only specify type `dgram` or `raw` |
| `pivot-root-trailing-slash` | Warning | `pivot_root` path doesn't end with `/` (paths refer to directories) |
| `profile-name-mismatch` | Warning | Top-level profile name does not match the document filename (e.g. profile `/usr/bin/foo` should be in file `usr.bin.foo`) |
| `duplicate-profile-name` | Error | Profile name is defined in more than one document; the later-loaded definition will silently replace the earlier one |
| `missing-local-include` | Warning | Top-level profile does not end with `include if exists <local/…>`, preventing local customisation |
| `apparmor-parser-error` | Error | Error reported by `apparmor_parser -Q -K`; attached to the file and line cited by the parser (may be an included abstraction) |

> **`file-rule-subsumed` and `rule-subsumed` — examples**
>
> ```apparmor
> profile example /usr/bin/example {
>     /usr/bin/foo r,       # flagged: subsumed by the rule below
>     /usr/bin/*   r,
>
>     capability net_bind_service,          # flagged: subsumed by the rule below
>     capability net_bind_service net_admin,
> }
> ```
>
> **Known limitations**
>
> Rules that appear inside qualifier blocks (`owner { }`, `audit { }`,
> `deny { }`, …) or inside `if defined` conditional blocks are excluded from
> the comparison.  Rules contributed by transitively included abstractions *are*
> checked — the diagnostic message names the top-level include directive that
> introduced the broader rule.  Rules inside `ProfileNode` bodies found in
> included files (sub-profiles) are not compared, as those represent separate
> confinement domains.  This applies to both `file-rule-subsumed` and
> `rule-subsumed`.
>
> For `file-rule-subsumed`: paths containing variable references (`@{VAR}`) are
> compared only when both rules have identical variable tokens at the same
> positions — `@{HOME}/foo` is not detected as subsumed by `/home/user/*` even
> if `@{HOME}` always expands to `/home/user`.  When both rules have partial
> globs in the same path-component position (e.g. `foo*` vs `foo?`), subsumption
> cannot be determined structurally and is conservatively skipped.

### Suppressing diagnostics

Place a `# apparmor-lint: ignore=<code>` comment at the end of a line where a rule is defined, or on the line immediately before
a rule, to silence one or more diagnostics for that rule only.  Multiple codes
can be listed comma-separated.  A blank line between the comment and the rule
cancels the annotation.  The space after `#` is optional.

```apparmor
profile example /usr/bin/example {
  # This rule intentionally uses unconfined exec — required for the wrapper.
  /usr/bin/helper ux, # apparmor-lint: ignore=dangerous-exec

  # Suppress multiple codes on one rule (ux on a path containing an
  # undefined variable).
  # apparmor-lint: ignore=dangerous-exec,undefined-variable
  @{helper_path} ux,

  # The annotation below is cleared by the blank line and has no effect.
  # apparmor-lint: ignore=unknown-capability

  capability not_a_real_cap,
}
```

#### Annotations at the top of a file

The **preamble** is the uninterrupted block of comment lines at the very start
of a file — it ends at the first blank line or the first non-comment statement,
whichever comes first.  Preamble comments are never attached to any node, which
protects copyright notices, SPDX identifiers, and other licence headers from
being accidentally deleted by code actions.

**Document-wide suppression:** A `# apparmor-lint: ignore=` annotation placed
in the preamble silences that diagnostic code for the *entire* file.  This is
the recommended way to suppress diagnostics such as `missing-abi-declaration`
and `missing-tunables-global` that are not attached to a specific statement:

```apparmor
# Copyright (C) 2024 Example Corp
# SPDX-License-Identifier: GPL-2.0
# apparmor-lint: ignore=missing-abi-declaration,missing-tunables-global
include <my-custom-tunables>
profile p { ... }
```

Multiple codes may be listed on one line (comma-separated) or spread across
several `# apparmor-lint: ignore=` lines in the preamble block — all are
applied.  Note that `parse-error` and `apparmor-parser-error` cannot be
suppressed this way.

**Important:** the preamble ends as soon as a non-comment statement appears.
If the first line of the file is a non-comment (e.g. `abi <abi/4.0>,`), any
annotation on the *next* line is already outside the preamble and will be
treated as a node-level annotation on the following statement — not as a
document-wide suppression.  To use document-wide suppression alongside an `abi`
declaration, place the annotation *before* the `abi` line:

```apparmor
# apparmor-lint: ignore=missing-tunables-global
abi <abi/4.0>,
include <my-custom-tunables>
profile p { ... }
```

**Node-level annotation after a copyright block:** To apply a `# apparmor-lint:
ignore=` annotation to only the *first* statement in the file (rather than the
whole document), place a blank line between the preamble and the annotation so
it falls outside the preamble zone:

```apparmor
# Copyright (C) 2024 Example Corp
# SPDX-License-Identifier: GPL-2.0

# apparmor-lint: ignore=missing-tunables-global
include <my-custom-tunables>
```

---

## Code actions reference

Quick fixes are offered automatically when a diagnostic appears on a line.
Editors typically show them via a lightbulb or a `<leader>ca` / Ctrl+. keybinding.

| Diagnostic code | Action title | Effect |
|---|---|---|
| `perm-conflict-write-append` | Remove 'a' (keep 'w') | Removes the `a` (append) permission from the file rule |
| `perm-conflict-write-append` | Remove 'w' (keep 'a') | Removes the `w` (write) permission from the file rule |
| `file-rule-subsumed` | Remove subsumed rule ⭐ | Deletes the redundant file rule (and any attached preceding comments) |
| `rule-subsumed` | Remove subsumed rule ⭐ | Deletes the redundant rule (and any attached preceding comments) |
| `duplicate-capability` | Remove duplicate capabilities ⭐ | Removes the duplicate capability tokens from the rule; deletes the entire rule if all of its capabilities are duplicates |
| `duplicate-permission` | Remove duplicate permissions ⭐ | Removes all duplicate permission tokens from the rule, preserving the first occurrence |
| `duplicate-signal-name` | Remove duplicate signal names ⭐ | Removes all duplicate signal names from the `set=(…)` list, preserving the first occurrence |
| `unused-variable` | Remove unused variable '…' ⭐ | Deletes the variable definition (and any attached preceding comments) |
| `unused-include` | Remove unused include '…' ⭐ | Deletes the include directive |
| `missing-abi-declaration` | Add 'abi <abi/N.M>,' ⭐ | Inserts the highest-numbered ABI declaration at the top of the preamble |
| `missing-tunables-global` | Add 'include <tunables/global>' ⭐ | Inserts the tunables/global include after any ABI declaration |
| `profile-name-mismatch` | Rename profile to '…' ⭐ | Replaces the profile name token with the name derived from the document filename |
| `missing-local-include` | Add 'include if exists <local/…>' ⭐ | Inserts `include if exists <local/NAME>` before the profile's closing brace |
| `unknown-capability`, `unknown-signal-name`, `unknown-signal-permission`, `unknown-ptrace-permission`, `unknown-dbus-permission`, `unknown-unix-permission`, `unknown-unix-type`, `unknown-mqueue-permission`, `unknown-mqueue-type`, `unknown-io-uring-permission`, `unknown-userns-permission`, `unknown-mount-option`, `unknown-rlimit-resource`, `unknown-network-qualifier`, `unknown-flag`, `unknown-keyword` | Replace 'X' with 'Y' ⭐ | Replaces the unknown token with the closest valid alternative (up to three suggestions offered, ranked by similarity) |
| `undefined-variable` | Replace '@{X}' with '@{Y}' ⭐ | Replaces the undefined variable reference with the closest defined variable name (up to three suggestions offered, ranked by similarity) |
| any rule diagnostic | Suppress 'code' for this rule | Inserts a `# apparmor-lint: ignore=code` comment immediately before the rule to silence that diagnostic |

⭐ Marked as the preferred action (used by editor auto-fix commands).

---

## Architecture

```
apparmor_language_server/
├── __init__.py         – package metadata
├── __main__.py         – python -m apparmor_language_server entry point
├── server.py           – pygls LSP server, all handler registration
├── indexer.py          – workspace indexer
├── parser.py           – line-oriented AST parser (profiles, rules, …)
├── nodes.py            – AST node dataclasses and visitor utilities (iter_children, walk, NodeVisitor)
├── constants.py        – capabilities, keywords, permissions, abstractions, …
├── render.py           – renders individual AST nodes back to text (RenderOptions, render_node dispatch)
├── completions.py      – context-aware completion provider
├── diagnostics.py      – linting / diagnostic checks
├── code_actions.py     – quick-fix code action provider
├── formatting.py       – AST-driven auto-formatter (returns TextEdits; calls render.py per node)
├── hover.py            – hover documentation provider
├── semantic_tokens.py  – semantic token provider (syntax highlighting)
├── lint.py             – standalone `apparmor-lint` CLI (parser + diagnostics, GCC/JSON output)
├── format.py           – standalone `apparmor-format` CLI (formatter, stdout/in-place/check/diff)
└── docs.py             – helpers for consistent hover/completion docs
```

### Adding a new node type

When a new node type is added to `nodes.py`, the dispatch tables in `hover.py`,
`render.py`, and `semantic_tokens.py` all need a corresponding entry, plus an
appropriate implementation function in each file (see sections below).

### Adding new checks

Create a new `_check_*` function in `diagnostics.py` and call it from
`_check_node()`. Each check receives the AST node and appends `Diagnostic`
objects to the list.

### Adding new hover documentation

Add a `_hover_<NodeType>(node, line_text, ch) -> Optional[Hover]` function in
`hover.py` and register it in the `_HOVER_DISPATCH` table at the bottom of
that file. Variable references (`@{VAR}`) within any node are resolved before
the dispatch, so handlers only need to cover node-specific keywords and fields.

### Adding new renderer support

Add a `render_<NodeType>` function in `render.py` following the `(node, opts: RenderOptions) -> str` signature, then register it in the `_RENDER_DISPATCH` table at the bottom of that file. `RenderOptions` carries `sort` (sort parenthesised lists) and `file_rule_style`.

### Adding new semantic token support

Add a `_tokens_for_<NodeType>` function in `semantic_tokens.py` following the `(node: NodeType) -> list[_Token]` signature, then register it in the `_DISPATCH` table at the bottom of that file. Emit individual tokens with `_add(tokens, raw, start_line, offset, length, token_type[, modifiers])`. Use `RE_ANY_VAR` from `constants.py` to highlight variable and boolean-variable references within values.

### Adding new code actions

Add a handler in `code_actions.py`: check `diag.code` in `get_code_actions()`,
find the relevant AST node with `walk()`, and return a `CodeAction` with a
`WorkspaceEdit` containing the corrective `TextEdit`s.

### Adding new completions

Add entries to the relevant `_complete_*` function in `completions.py`, or
extend the `get_completions()` dispatcher with a new regex trigger.

---

## Development

```bash
# Install dev dependencies (includes pytest, ruff, ty, etc.)
pip install -e ".[dev]"

# Run tests
pytest tests/ -v

# Run tests with coverage, outputting both terminal and JSON reports
pytest --cov=apparmor_language_server --cov-report=term --cov-report=json

# Lint and format
ruff check apparmor_language_server/
ruff format apparmor_language_server/

# Type-check
ty check apparmor_language_server/

# Run the server in TCP mode for interactive debugging
python -m apparmor_language_server --tcp --port 2087
```

---

## Licence

GPL 3.0 or later
