@felixgeelhaar/cclint
Version:
Catch CLAUDE.md drift before Claude misbehaves. Lints CLAUDE.md, skills, subagents, and hooks for Claude Code projects.
178 lines • 6.84 kB
JavaScript
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { createConnection, TextDocuments, ProposedFeatures, TextDocumentSyncKind, CodeActionKind, } from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { ContextFile } from '../domain/ContextFile.js';
import { RulesEngine } from '../domain/RulesEngine.js';
import { createRules } from '../rules/registry/createRules.js';
import { ConfigLoader } from '../infrastructure/ConfigLoader.js';
import { violationsToDiagnostics, violationToCodeAction, } from './diagnostics.js';
/**
* Resolve a text-document URI to a filesystem path.
*
* @remarks
* `file://` URIs are decoded to real paths so file-kind gating (a rule's
* {@link ContextFile}-based `appliesTo`) sees, e.g., `.claude/skills/…` and
* `settings.json`. Non-`file:` URIs (such as `untitled:`) are passed through
* unchanged — they never resolve to a real config file, so config lookup simply
* falls back to defaults and structure rules are gated off by extension.
*/
export function uriToFsPath(uri) {
if (uri.startsWith('file://')) {
return fileURLToPath(uri);
}
return uri;
}
/**
* Build a {@link RulesEngine} configured for the workspace that owns a given
* document path, honouring `.cclintrc.json` / presets discovered upward from
* the document's directory.
*
* @remarks
* Deliberately constructed *without* per-rule severity overrides — matching the
* MCP, watch, and Action adapters. `RulesEngine` applies an override by
* reconstructing each violation, which discards its structured `fix`; skipping
* overrides keeps fixes intact so `textDocument/codeAction` can offer quick
* fixes. Diagnostics therefore reflect each rule's intrinsic severity. (The CLI
* `lint` command, which does not need fixes at report time, is the only entry
* point that opts into overrides.)
*/
function engineForPath(fsPath) {
const config = ConfigLoader.load(dirname(fsPath));
return new RulesEngine(createRules(config));
}
/**
* Construct a {@link ContextFile} from a document's path and in-memory text, or
* `undefined` when the text violates the domain content limits.
*
* @remarks
* The editor holds the authoritative buffer, so we lint the live text directly
* rather than reading from disk. The content-limit guard in {@link ContextFile}
* can throw on pathological input (e.g. a multi-megabyte single line); we treat
* that as "nothing to report" so a keystroke never crashes the server.
*/
function toContextFile(fsPath, text) {
try {
return new ContextFile(fsPath, text);
}
catch {
return undefined;
}
}
/**
* Lint an open document's live text and return LSP diagnostics.
*
* Pure with respect to the LSP connection: it can be exercised in unit tests by
* passing a {@link TextDocument} built with `TextDocument.create`.
*/
export function lintTextDocument(document) {
const fsPath = uriToFsPath(document.uri);
const file = toContextFile(fsPath, document.getText());
if (!file) {
return [];
}
const result = engineForPath(fsPath).lint(file);
return violationsToDiagnostics(result);
}
/** Whether two line spans (inclusive) overlap. */
function linesOverlap(aStart, aEnd, bStart, bEnd) {
return aStart <= bEnd && bStart <= aEnd;
}
/**
* Compute quick-fix code actions for the violations that fall within the
* requested range and carry a structured fix.
*
* Pure with respect to the LSP connection, so it is unit-testable directly.
*/
export function computeCodeActions(document, range) {
const fsPath = uriToFsPath(document.uri);
const file = toContextFile(fsPath, document.getText());
if (!file) {
return [];
}
const result = engineForPath(fsPath).lint(file);
const actions = [];
for (const violation of result.violations) {
if (!violation.fix) {
continue;
}
// The fix's own span is 1-based; compare on 0-based lines against the
// requested range so we only surface fixes relevant to the user's cursor.
const fixStartLine = violation.fix.range.start.line - 1;
const fixEndLine = violation.fix.range.end.line - 1;
if (!linesOverlap(fixStartLine, fixEndLine, range.start.line, range.end.line)) {
continue;
}
const action = violationToCodeAction(violation, document.uri);
if (action) {
actions.push(action);
}
}
return actions;
}
/**
* The server's advertised capabilities.
*
* @remarks
* Incremental text sync keeps large documents cheap to update, and a quick-fix
* code action provider exposes the fixes rules attach to their violations.
*/
export function buildInitializeResult() {
return {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix],
},
},
};
}
/**
* Wire cclint's linting pipeline onto an LSP {@link Connection}: publish
* diagnostics on open/change/save, clear them on close, and serve quick-fix
* code actions.
*
* @remarks
* Split out from {@link startServer} so a test can drive it with a fake
* connection if desired. `documents.onDidChangeContent` fires on both open and
* change, so it covers `textDocument/didOpen` and `textDocument/didChange`;
* `onDidSave` re-lints on `textDocument/didSave`.
*/
export function registerHandlers(connection) {
const documents = new TextDocuments(TextDocument);
connection.onInitialize(() => buildInitializeResult());
const publish = (document) => {
const diagnostics = lintTextDocument(document);
void connection.sendDiagnostics({ uri: document.uri, diagnostics });
};
documents.onDidChangeContent(event => publish(event.document));
documents.onDidSave(event => publish(event.document));
documents.onDidClose(event => {
void connection.sendDiagnostics({
uri: event.document.uri,
diagnostics: [],
});
});
connection.onCodeAction(params => {
const document = documents.get(params.textDocument.uri);
if (!document) {
return [];
}
return computeCodeActions(document, params.range);
});
documents.listen(connection);
}
/**
* Start the cclint language server over stdio.
*
* @remarks
* Called by the `cclint-lsp` bin. Creates the connection, registers handlers,
* and begins listening. No work happens at module import time, so importing
* this module in tests is side-effect free.
*/
export function startServer() {
const connection = createConnection(ProposedFeatures.all);
registerHandlers(connection);
connection.listen();
}
//# sourceMappingURL=server.js.map