Language tooling
Editor core defines backend-neutral contracts for language behavior and tooling. It does not start an LSP server, load a Tree-sitter library, or perform file I/O. Those adapters live in the application or a separate package.
Language adapters
Implement EditorLanguageAdapter for comment syntax, pairs, and indentation:
final class MyLanguage extends EditorLanguageAdapter {
const MyLanguage();
String get languageId => 'my-language';
List<String> get aliases => const ['mine'];
EditorCommentConfig get comments => const EditorCommentConfig(
linePrefix: '//',
blockStart: '/*',
blockEnd: '*/',
);
bool shouldIncreaseIndent(EditorIndentContext context) {
return context.lineBeforeCursor.trimRight().endsWith(':');
}
}
final languages = EditorLanguageRegistry()..register(const MyLanguage());
final adapter = languages.require('mine');
The core registry begins empty. registerBuiltinEditorLanguages() registers
the bundled profiles for hosts that want the opinionated defaults.
The public package:artisanal/editor_core.dart entrypoint currently re-exports
the builtin code extensions for compatibility. Custom language packages
should still program against EditorLanguageAdapter and avoid relying on
global registration.
Syntax decorations
A synchronous provider returns either a full decoration list or an incremental patch:
final class DemoSyntaxProvider extends TextSyntaxProvider<int> {
TextSyntaxBuildResult<int> build(
String text, {
TextDocument? document,
String? language,
TextSyntaxSnapshot<int>? previous,
TextDocumentChange? change,
}) {
final keywordEnd = text.startsWith('import') ? 6 : 0;
return TextSyntaxBuildResult(
decorations: [
if (keywordEnd > 0)
TextDecorationRange(
startOffset: 0,
endOffset: keywordEnd,
styleKey: 'syntax.keyword',
),
],
state: (previous?.state ?? 0) + 1,
);
}
}
final session = TextSyntaxSession<int>(
provider: DemoSyntaxProvider(),
language: 'dart',
);
final snapshot = session.syncDocument(document);
Pass the accepted snapshot to TextAreaModel.setDecorationLayer, or use
TextAreaModel.syncSyntax to coordinate the session and layer.
For incremental providers:
- consume
TextDocumentChange; - calculate a bounded line window with
textSyntaxChangeWindow; - parse that window with enough look-behind/look-ahead;
- return
TextSyntaxBuildResult.patch; - let the session merge and shift unaffected ranges.
Asynchronous syntax
Use AsyncTextSyntaxProvider and AsyncTextSyntaxSession when parsing should
not block the render isolate:
final snapshot = await asyncSession.request(
document.copy(),
change: lastChange,
);
if (snapshot != null) {
editor.setDecorationLayer(
textSyntaxDecorationLayerKey,
snapshot.decorations,
priority: textSyntaxDecorationLayerPriority,
);
}
null means a newer request or cancellation superseded the result. Never
publish the provider's raw result without this generation check.
Consult EditorWorkBudget before synchronous whole-document parsing.
Syntax trees
EditorSyntaxNode, EditorSyntaxTree, EditorSyntaxCapture, and
SyntaxTreeEdit are pure Dart DTOs for structural backends.
SyntaxTreeProvider is the integration boundary for a Tree-sitter-like parser.
For native workers, isolates, and other future-based parsers, implement
AsyncSyntaxTreeProvider and submit snapshots through
AsyncSyntaxTreeSession. The session retains the previous document/tree for
incremental parsing and rejects responses superseded by newer requests.
Use syntax trees for:
- node and ancestor queries;
- structural selections and text objects;
- syntax-aware folds;
- comment/string filtering for bracket matching;
- semantic decorations;
- code actions that need syntax context.
Native bindings belong in a dependent package. Editor core remains free of
dart:ffi.
The optional
tree_sitter_language_pack adapter example
shows this boundary end to end. It owns native runtime initialization,
implements AsyncSyntaxTreeProvider, parses outside the render path, and maps
the package's UTF-8 byte spans into editor grapheme offsets. Its live editor
walks the complete native tree to decorate lexical syntax nodes. The high-level
language-pack API performs full parses; adapters that retain native trees should
additionally apply SyntaxTreeEdits and opt into incremental parsing.
Tree-sitter-style parsers usually report UTF-8 byte coordinates, while editor
documents use grapheme coordinates. Create one TextUtf8CoordinateIndex for
each parser request and reuse it when mapping nodes and captures:
final coordinates = TextUtf8CoordinateIndex(document);
final start = coordinates.offsetForPoint(
TextUtf8Point(row: capture.startRow, byteColumn: capture.startByteColumn),
);
final end = coordinates.offsetForByteOffset(capture.endByteOffset);
Byte positions inside a multi-byte grapheme round down to its leading boundary. The standalone conversion functions are convenient for one-off lookups; the index avoids rescanning the document for every parser range.
Search and replace
final result = findTextSearchMatches(
document,
const TextSearchQuery(
pattern: r'final\s+(\w+)',
isRegex: true,
caseSensitive: true,
),
maxResults: 1000,
);
if (result.error != null) {
// Invalid regexes are normal while the user types.
}
Search offsets are converted back into document grapheme coordinates.
TextSearchResult.truncated reports when maxResults stopped materialization.
Replacement templates support:
$&for the whole match;$1through$99for capture groups;$$for a literal dollar.
TextSearchSession supplies next/previous navigation. TextAreaModel adds
decoration layers plus replaceActiveSearchMatch and
replaceAllSearchMatches, grouping each operation into normal history.
Completions
Implement EditorCompletionProvider:
final class DemoCompletions implements EditorCompletionProvider {
Future<EditorCompletionResult> provide(
EditorCompletionRequest request,
) async {
return const EditorCompletionResult(
items: [
EditorCompletionItem(
label: 'print',
insertText: 'print',
kind: EditorCompletionKind.function,
detail: 'Print a value',
),
],
);
}
}
In a TEA host:
final cmd = editor.requestCompletions(DemoCompletions());
// Return cmd from Model.update. TextAreaCompletionMsg returns through TEA.
Render completionItems, completionIndex, and activeCompletion in a
popup. Forward navigation to moveCompletionSelection; call
acceptCompletion or cancelCompletions.
Acceptance applies the primary insertion as one undo transaction.
consumeCompletionAdditionalEdits() hands import or cross-file changes back
to the host exactly once.
Code actions
An EditorCodeActionProvider receives current text, version, selection, and
intersecting diagnostics. It returns quick fixes or refactors:
final action = EditorCodeAction(
title: 'Replace with const',
kind: 'quickfix',
preferred: true,
edit: const WorkspaceEdit(
files: {
'lib/main.dart': [
FileTextEdit(
startOffset: 0,
endOffset: 5,
replacement: 'const',
),
],
},
),
);
TextAreaModel.requestCodeActions returns a Cmd. Render codeActions and
activeCodeAction, then call acceptCodeAction. The host consumes the result
with consumeAcceptedCodeAction() and decides how to preview/apply edits or
dispatch commandId.
Workspace edits
WorkspaceEdit is intentionally I/O-free:
final files = <String, String>{
'lib/main.dart': 'final value = 1;\n',
};
final preview = previewWorkspaceEdit(files, action.edit);
final applied = applyWorkspaceEdit(files, action.edit);
if (applied.values.every((result) => result.applied)) {
// The host may now persist result.newText for each file.
}
Edits are validated for overlap and applied from highest offset to lowest. Conflicting edits reject the whole affected file. Always preview user-visible refactors before writing, and keep reads/writes in the host.
applyFileEdits operates directly on Dart strings, so FileTextEdit offsets
passed to that helper are UTF-16 code-unit indexes. Convert document grapheme
offsets before using the string helper, or apply the replacement to a
TextDocument when you want editor-coordinate semantics.
Snippets
final snippet = parseSnippet(
'for (var \${1:i} = 0; \$1 < \${2:n}; \$1++) {\n \$0\n}',
);
final session = SnippetSession(snippet);
final first = session.next();
Supported syntax includes $1, ${1}, ${1:default}, mirrors, and $0 as
the final stop. SnippetSession visits the first occurrence of each stop in
numeric order, with $0 last.
The host inserts ParsedSnippet.text, offsets each tabstop by the insertion
origin, and selects each returned range. Live mirror synchronization while the
user edits is a host responsibility.
Bracket matching
final partner = findMatchingBracket(document, cursorOffset);
final pair = bracketPairRange(document, cursorOffset);
The built-in matcher understands (), [], and {} and performs depth
counting. Quotes are excluded because they require token context. Filter
brackets through syntax information when comments or strings must be ignored.