Skip to content

Commit 997b721

Browse files
committed
feat: TextDocument and incremental sync helper
1 parent e282982 commit 997b721

5 files changed

Lines changed: 1063 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import 'dart:io';
2+
3+
import 'package:collection/collection.dart';
4+
import 'package:lsp_server/lsp_server.dart';
5+
6+
void main() async {
7+
// Create a connection that can read and write data to the LSP client.
8+
// Supply a readable and writable stream. In this case we are using stdio.
9+
// But you could use a socket connection or any other stream.
10+
var connection = Connection(stdin, stdout);
11+
12+
// Create a TextDocuments handler. This class gives support for both full
13+
// and incremental sync. The document returned by this handler is the
14+
// TextDocument class, which has an API that matches
15+
// vscode-languageserver-textdocument.
16+
var documents = TextDocuments(connection, onDidChangeContent: (params) async {
17+
// onDidChangeContent is called both when a document is opened
18+
// and when it changes. It's a great place to run diagnostics.
19+
var diagnostics = _validateTextDocument(
20+
params.document.getText(),
21+
params.document.uri.toString(),
22+
);
23+
24+
// Send back an event notifying the client of issues we want them to render.
25+
// To clear issues the server is responsible for sending an empty list.
26+
connection.sendDiagnostics(
27+
PublishDiagnosticsParams(
28+
diagnostics: diagnostics,
29+
uri: params.document.uri,
30+
),
31+
);
32+
});
33+
34+
// Register a listener for when the client initialzes the server.
35+
// You are suppose to respond with the capabilities of the server.
36+
// Some capabilities must be enabled by the client, you can see what the client
37+
// supports by inspecting the ClientCapabilities object, inside InitializeParams.
38+
connection.onInitialize((params) async {
39+
return InitializeResult(
40+
capabilities: ServerCapabilities(
41+
// In this example we are using the Incremental sync mode. This means
42+
// only the content that has changed is sent, and it's up to the server
43+
// to update its state accordingly. TextDocuments and TextDocument
44+
// handle this for you.
45+
textDocumentSync: const Either2.t1(TextDocumentSyncKind.Incremental),
46+
// Tell the client what we can do
47+
diagnosticProvider: Either2.t1(DiagnosticOptions(
48+
interFileDependencies: true, workspaceDiagnostics: false)),
49+
hoverProvider: Either2.t1(true),
50+
),
51+
);
52+
});
53+
54+
// Your other listeners likely want to get the synced TextDocument based
55+
// on the params' TextDocumentIdentifier.
56+
connection.onHover((params) async {
57+
var textDocument = documents.get(params.textDocument.uri);
58+
var lines = textDocument?.lineCount ?? 0;
59+
return Hover(contents: Either2.t2('Document has $lines lines'));
60+
});
61+
62+
await connection.listen();
63+
}
64+
65+
// Validate the text document and return a list of diagnostics.
66+
// Will find each occurence of more than two uppercase letters in a row.
67+
// Each reported value will come with the indexed location in the file,
68+
// by line and column.
69+
List<Diagnostic> _validateTextDocument(String text, String sourcePath) {
70+
RegExp pattern = RegExp(r'\b[A-Z]{2,}\b');
71+
72+
final lines = text.split('\n');
73+
74+
final matches = lines.map((line) => pattern.allMatches(line));
75+
76+
final diagnostics = matches
77+
.mapIndexed(
78+
(line, lineMatches) => _convertPatternToDiagnostic(lineMatches, line),
79+
)
80+
.reduce((aggregate, diagnostics) => [...aggregate, ...diagnostics])
81+
.toList();
82+
83+
return diagnostics;
84+
}
85+
86+
// Convert each line that has uppercase strings into a list of diagnostics.
87+
// The line "AAA bbb CCC" would be converted into two diagnostics:
88+
// One for "AAA".
89+
// One for "CCC".
90+
Iterable<Diagnostic> _convertPatternToDiagnostic(
91+
Iterable<RegExpMatch> matches, int line) {
92+
return matches.map(
93+
(match) => Diagnostic(
94+
message:
95+
'${match.input.substring(match.start, match.end)} is all uppercase.',
96+
range: Range(
97+
start: Position(character: match.start, line: line),
98+
end: Position(character: match.end, line: line),
99+
),
100+
),
101+
);
102+
}

lib/lsp_server.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export 'src/lsp_server_base.dart';
2+
export 'src/text_document.dart';
3+
export 'src/text_documents.dart';
24
export 'src/protocol/lsp_protocol/protocol_generated.dart';
35
export 'src/protocol/lsp_protocol/protocol_special.dart';

lib/src/text_document.dart

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
import 'dart:math';
2+
3+
import 'package:lsp_server/lsp_server.dart';
4+
5+
// \n
6+
const lineFeed = 10;
7+
// \r
8+
const carriageReturn = 13;
9+
10+
/// Mimics vscode-languageserver-node's
11+
/// [TextDocument](https://github.com/microsoft/vscode-languageserver-node/blob/main/textDocument/src/main.ts)
12+
class TextDocument {
13+
final Uri _uri;
14+
final String _languageId;
15+
int _version;
16+
String _content;
17+
List<int>? _lineOffsets;
18+
19+
TextDocument(this._uri, this._languageId, this._version, this._content);
20+
21+
/// The associated URI for this document. Most documents have the file scheme, indicating that they
22+
/// represent files on disk. However, some documents may have other schemes indicating that they
23+
/// are not available on disk.
24+
Uri get uri => _uri;
25+
26+
/// The identifier of the language associated with this document.
27+
String get languageId => _languageId;
28+
29+
/// The version number of this document (it will increase after each change,
30+
/// including undo/redo).
31+
int get version => _version;
32+
33+
/// The number of lines in this document.
34+
int get lineCount => _getLineOffsets().length;
35+
36+
String applyEdits(List<TextEdit> edits) {
37+
var sortedEdits = edits.map(_getWellformedTextEdit).toList();
38+
sortedEdits.sort((a, b) {
39+
var diff = a.range.start.line - b.range.start.line;
40+
if (diff == 0) {
41+
return a.range.start.character - b.range.start.character;
42+
}
43+
return diff;
44+
});
45+
46+
var text = getText();
47+
var lastModifiedOffset = 0;
48+
List<String> spans = [];
49+
50+
for (var edit in sortedEdits) {
51+
var startOffset = offsetAt(edit.range.start);
52+
if (startOffset < lastModifiedOffset) {
53+
throw 'Overlapping edit';
54+
} else if (startOffset > lastModifiedOffset) {
55+
spans.add(text.substring(lastModifiedOffset, startOffset));
56+
}
57+
if (edit.newText.isNotEmpty) {
58+
spans.add(edit.newText);
59+
}
60+
lastModifiedOffset = offsetAt(edit.range.end);
61+
}
62+
spans.add(text.substring(lastModifiedOffset));
63+
return spans.join();
64+
}
65+
66+
/// Get the text of this document. Provide a [Range] to get a substring.
67+
String getText({Range? range}) {
68+
if (range != null) {
69+
var start = offsetAt(range.start);
70+
var end = offsetAt(range.end);
71+
return _content.substring(start, end);
72+
}
73+
return _content;
74+
}
75+
76+
/// Convert a [Position] to a zero-based offset.
77+
int offsetAt(Position position) {
78+
var lineOffsets = _getLineOffsets();
79+
if (position.line >= lineOffsets.length) {
80+
return _content.length;
81+
} else if (position.line < 0) {
82+
return 0;
83+
}
84+
85+
var lineOffset = lineOffsets[position.line];
86+
if (position.character <= 0) {
87+
return lineOffset;
88+
}
89+
90+
var nextLineOffset = (position.line + 1 < lineOffsets.length)
91+
? lineOffsets[position.line + 1]
92+
: _content.length;
93+
var offset = min(lineOffset + position.character, nextLineOffset);
94+
95+
return _ensureBeforeEndOfLine(offset: offset, lineOffset: lineOffset);
96+
}
97+
98+
/// Converts a zero-based offset to a [Position].
99+
Position positionAt(int offset) {
100+
offset = max(min(offset, _content.length), 0);
101+
var lineOffsets = _getLineOffsets();
102+
var low = 0;
103+
var high = lineOffsets.length;
104+
if (high == 0) {
105+
return Position(character: offset, line: 0);
106+
}
107+
108+
while (low < high) {
109+
var mid = ((low + high) / 2).floor();
110+
if (lineOffsets[mid] > offset) {
111+
high = mid;
112+
} else {
113+
low = mid + 1;
114+
}
115+
}
116+
117+
var line = low - 1;
118+
offset = _ensureBeforeEndOfLine(
119+
offset: offset,
120+
lineOffset: lineOffsets[line],
121+
);
122+
123+
return Position(character: offset - lineOffsets[line], line: line);
124+
}
125+
126+
/// Updates this text document by modifying its content.
127+
void update(List<TextDocumentContentChangeEvent> changes, int version) {
128+
_version = version;
129+
for (var c in changes) {
130+
var change = c.map((v) => v, (v) => v);
131+
if (change is TextDocumentContentChangeEvent1) {
132+
// Incremental sync.
133+
var range = _getWellformedRange(change.range);
134+
var text = change.text;
135+
136+
var startOffset = offsetAt(range.start);
137+
var endOffset = offsetAt(range.end);
138+
139+
// Update content.
140+
_content = _content.substring(0, startOffset) +
141+
text +
142+
_content.substring(endOffset, _content.length);
143+
144+
// Update offsets without recomputing for the whole document.
145+
var startLine = max(range.start.line, 0);
146+
var endLine = max(range.end.line, 0);
147+
var lineOffsets = _lineOffsets!;
148+
var addedLineOffsets = _computeLineOffsets(text,
149+
isAtLineStart: false, textOffset: startOffset);
150+
151+
if (endLine - startLine == addedLineOffsets.length) {
152+
for (var i = 0, len = addedLineOffsets.length; i < len; i++) {
153+
lineOffsets[i + startLine + 1] = addedLineOffsets[i];
154+
}
155+
} else {
156+
// Avoid going outside the range on weird range inputs.
157+
lineOffsets.replaceRange(
158+
min(startLine + 1, lineOffsets.length),
159+
min(endLine + 1, lineOffsets.length),
160+
addedLineOffsets,
161+
);
162+
}
163+
164+
var diff = text.length - (endOffset - startOffset);
165+
if (diff != 0) {
166+
for (var i = startLine + 1 + addedLineOffsets.length,
167+
len = lineOffsets.length;
168+
i < len;
169+
i++) {
170+
lineOffsets[i] = lineOffsets[i] + diff;
171+
}
172+
}
173+
} else if (change is TextDocumentContentChangeEvent2) {
174+
// Full sync.
175+
_content = change.text;
176+
_lineOffsets = null;
177+
}
178+
}
179+
}
180+
181+
List<int> _getLineOffsets() {
182+
_lineOffsets ??= _computeLineOffsets(_content, isAtLineStart: true);
183+
return _lineOffsets!;
184+
}
185+
186+
List<int> _computeLineOffsets(String content,
187+
{required bool isAtLineStart, int textOffset = 0}) {
188+
List<int> result = isAtLineStart ? [textOffset] : [];
189+
190+
for (var i = 0; i < content.length; i++) {
191+
var char = content.codeUnitAt(i);
192+
if (_isEndOfLine(char)) {
193+
if (char == carriageReturn) {
194+
var nextCharIsLineFeed =
195+
i + 1 < content.length && content.codeUnitAt(i + 1) == lineFeed;
196+
if (nextCharIsLineFeed) {
197+
i++;
198+
}
199+
}
200+
result.add(textOffset + i + 1);
201+
}
202+
}
203+
204+
return result;
205+
}
206+
207+
bool _isEndOfLine(int char) {
208+
return char == lineFeed || char == carriageReturn;
209+
}
210+
211+
int _ensureBeforeEndOfLine({required int offset, required int lineOffset}) {
212+
while (
213+
offset > lineOffset && _isEndOfLine(_content.codeUnitAt(offset - 1))) {
214+
offset--;
215+
}
216+
return offset;
217+
}
218+
219+
Range _getWellformedRange(Range range) {
220+
var start = range.start;
221+
var end = range.end;
222+
if (start.line > end.line ||
223+
(start.line == end.line && start.character > end.character)) {
224+
return Range(start: end, end: start);
225+
}
226+
return range;
227+
}
228+
229+
TextEdit _getWellformedTextEdit(TextEdit textEdit) {
230+
var range = _getWellformedRange(textEdit.range);
231+
if (range != textEdit.range) {
232+
return TextEdit(newText: textEdit.newText, range: range);
233+
}
234+
return textEdit;
235+
}
236+
}

0 commit comments

Comments
 (0)