Skip to content

Commit 05b7e05

Browse files
key-moonaqrln
andauthored
fix(prisma-fmt): use UTF-16 offset in the response for the schema that contains multi-byte characters (#4815)
Updated offsets to be UTF-16. This means that our spans will no-longer de-sync with the schema when we run into schemas containing multibyte characters --------- Co-authored-by: Alexey Orlenko <[email protected]>
1 parent 4c3db41 commit 05b7e05

File tree

10 files changed

+280
-179
lines changed

10 files changed

+280
-179
lines changed

prisma-fmt/src/code_actions.rs

+8-17
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod multi_schema;
44
mod relation_mode;
55
mod relations;
66

7+
use crate::offsets::{position_after_span, range_to_span, span_to_range};
78
use log::warn;
89
use lsp_types::{CodeActionOrCommand, CodeActionParams, Diagnostic, Range, TextEdit, Url, WorkspaceEdit};
910
use psl::{
@@ -47,7 +48,7 @@ impl<'a> CodeActionsContext<'a> {
4748
#[track_caller]
4849
pub(super) fn diagnostics_for_span(&self, span: ast::Span) -> impl Iterator<Item = &Diagnostic> {
4950
self.diagnostics().iter().filter(move |diag| {
50-
span.overlaps(crate::range_to_span(
51+
span.overlaps(range_to_span(
5152
diag.range,
5253
self.initiating_file_source(),
5354
self.initiating_file_id,
@@ -167,7 +168,7 @@ fn create_missing_attribute<'a>(
167168
let new_text = format!(" @{attribute_name}");
168169

169170
let field = fields.next().unwrap();
170-
let position = crate::position_after_span(field.ast_field().span(), schema);
171+
let position = position_after_span(field.ast_field().span(), schema);
171172

172173
let range = Range {
173174
start: position,
@@ -187,25 +188,15 @@ fn create_missing_attribute<'a>(
187188
&model.ast_model().attributes,
188189
);
189190

190-
let range = range_after_span(schema, model.ast_model().span());
191+
let range = range_after_span(model.ast_model().span(), schema);
191192
(formatted_attribute, range)
192193
};
193194

194195
TextEdit { range, new_text }
195196
}
196197

197-
fn range_after_span(schema: &str, span: Span) -> Range {
198-
let start = crate::offset_to_position(span.end - 1, schema);
199-
let end = crate::offset_to_position(span.end, schema);
200-
201-
Range { start, end }
202-
}
203-
204-
fn span_to_range(schema: &str, span: Span) -> Range {
205-
let start = crate::offset_to_position(span.start, schema);
206-
let end = crate::offset_to_position(span.end, schema);
207-
208-
Range { start, end }
198+
fn range_after_span(span: Span, schema: &str) -> Range {
199+
span_to_range(Span::new(span.end - 1, span.end, span.file_id), schema)
209200
}
210201

211202
fn format_field_attribute(attribute: &str) -> String {
@@ -253,8 +244,8 @@ fn create_text_edit(
253244
span: Span,
254245
) -> Result<WorkspaceEdit, Box<dyn std::error::Error>> {
255246
let range = match append {
256-
true => range_after_span(target_file_content, span),
257-
false => span_to_range(target_file_content, span),
247+
true => range_after_span(span, target_file_content),
248+
false => span_to_range(span, target_file_content),
258249
};
259250

260251
let text = TextEdit {

prisma-fmt/src/code_actions/block.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ pub(super) fn create_missing_block_for_model(
2828
file_id: span_model.file_id,
2929
};
3030

31-
let range = super::range_after_span(context.initiating_file_source(), span);
31+
let range = super::range_after_span(span, context.initiating_file_source());
3232

3333
diagnostics.iter().for_each(|diag| {
3434
push_missing_block(
@@ -83,7 +83,7 @@ pub(super) fn create_missing_block_for_type(
8383
file_id: span_type.file_id,
8484
};
8585

86-
let range = super::range_after_span(context.initiating_file_source(), span);
86+
let range = super::range_after_span(span, context.initiating_file_source());
8787
diagnostics.iter().for_each(|diag| {
8888
push_missing_block(
8989
diag,

prisma-fmt/src/code_actions/relations.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ pub(super) fn add_index_for_relation_fields(
271271
&relation.model().ast_model().attributes,
272272
);
273273

274-
let range = super::range_after_span(context.initiating_file_source(), relation.model().ast_model().span());
274+
let range = super::range_after_span(relation.model().ast_model().span(), context.initiating_file_source());
275275
let text = TextEdit {
276276
range,
277277
new_text: formatted_attribute,

prisma-fmt/src/lib.rs

+2-131
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ mod get_dmmf;
55
mod lint;
66
mod merge_schemas;
77
mod native;
8+
mod offsets;
89
mod preview;
910
mod schema_file_input;
1011
mod text_document_completion;
1112
mod validate;
1213

1314
use log::*;
14-
use lsp_types::{Position, Range};
15-
use psl::{diagnostics::FileId, parser_database::ast};
15+
pub use offsets::span_to_range;
1616
use schema_file_input::SchemaFileInput;
1717

1818
/// The API is modelled on an LSP [completion
@@ -221,132 +221,3 @@ pub fn get_config(get_config_params: String) -> String {
221221
pub fn get_dmmf(get_dmmf_params: String) -> Result<String, String> {
222222
get_dmmf::get_dmmf(&get_dmmf_params)
223223
}
224-
225-
/// The LSP position is expressed as a (line, col) tuple, but our pest-based parser works with byte
226-
/// offsets. This function converts from an LSP position to a pest byte offset. Returns `None` if
227-
/// the position has a line past the end of the document, or a character position past the end of
228-
/// the line.
229-
pub(crate) fn position_to_offset(position: &Position, document: &str) -> Option<usize> {
230-
let mut offset = 0;
231-
let mut line_offset = position.line;
232-
let mut character_offset = position.character;
233-
let mut chars = document.chars();
234-
235-
while line_offset > 0 {
236-
loop {
237-
match chars.next() {
238-
Some('\n') => {
239-
offset += 1;
240-
break;
241-
}
242-
Some(_) => {
243-
offset += 1;
244-
}
245-
None => return Some(offset),
246-
}
247-
}
248-
249-
line_offset -= 1;
250-
}
251-
252-
while character_offset > 0 {
253-
match chars.next() {
254-
Some('\n') | None => return Some(offset),
255-
Some(_) => {
256-
offset += 1;
257-
character_offset -= 1;
258-
}
259-
}
260-
}
261-
262-
Some(offset)
263-
}
264-
265-
#[track_caller]
266-
/// Converts an LSP range to a span.
267-
pub(crate) fn range_to_span(range: Range, document: &str, file_id: FileId) -> ast::Span {
268-
let start = position_to_offset(&range.start, document).unwrap();
269-
let end = position_to_offset(&range.end, document).unwrap();
270-
271-
ast::Span::new(start, end, file_id)
272-
}
273-
274-
/// Gives the LSP position right after the given span, skipping any trailing newlines
275-
pub(crate) fn position_after_span(span: ast::Span, document: &str) -> Position {
276-
let end = match (document.chars().nth(span.end - 2), document.chars().nth(span.end - 1)) {
277-
(Some('\r'), Some('\n')) => span.end - 2,
278-
(_, Some('\n')) => span.end - 1,
279-
_ => span.end,
280-
};
281-
282-
offset_to_position(end, document)
283-
}
284-
285-
/// Converts a byte offset to an LSP position, if the given offset
286-
/// does not overflow the document.
287-
pub fn offset_to_position(offset: usize, document: &str) -> Position {
288-
let mut position = Position::default();
289-
290-
for (i, chr) in document.chars().enumerate() {
291-
match chr {
292-
_ if i == offset => {
293-
return position;
294-
}
295-
'\n' => {
296-
position.character = 0;
297-
position.line += 1;
298-
}
299-
_ => {
300-
position.character += 1;
301-
}
302-
}
303-
}
304-
305-
position
306-
}
307-
308-
#[cfg(test)]
309-
mod tests {
310-
use lsp_types::Position;
311-
use psl::diagnostics::{FileId, Span};
312-
313-
use crate::position_after_span;
314-
315-
// On Windows, a newline is actually two characters.
316-
#[test]
317-
fn position_to_offset_with_crlf() {
318-
let schema = "\r\nmodel Test {\r\n id Int @id\r\n}";
319-
// Let's put the cursor on the "i" in "id Int".
320-
let expected_offset = schema.chars().position(|c| c == 'i').unwrap();
321-
let found_offset = super::position_to_offset(&Position { line: 2, character: 4 }, schema).unwrap();
322-
323-
assert_eq!(found_offset, expected_offset);
324-
}
325-
326-
#[test]
327-
fn position_after_span_no_newline() {
328-
let str = "some string";
329-
let span = Span::new(0, str.len(), FileId::ZERO);
330-
let pos = position_after_span(span, str);
331-
assert_eq!(pos.line, 0);
332-
assert_eq!(pos.character, 11);
333-
}
334-
335-
#[test]
336-
fn position_after_span_lf() {
337-
let str = "some string\n";
338-
let span = Span::new(0, str.len(), FileId::ZERO);
339-
let pos = position_after_span(span, str);
340-
assert_eq!(pos.line, 0);
341-
assert_eq!(pos.character, 11);
342-
}
343-
344-
#[test]
345-
fn position_after_span_crlf() {
346-
let str = "some string\r\n";
347-
let span = Span::new(0, str.len(), FileId::ZERO);
348-
let pos = position_after_span(span, str);
349-
assert_eq!(pos.line, 0);
350-
assert_eq!(pos.character, 11);
351-
}
352-
}

prisma-fmt/src/lint.rs

+47-15
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
use psl::diagnostics::{DatamodelError, DatamodelWarning};
1+
use crate::offsets::span_to_lsp_offsets;
2+
use psl::{
3+
diagnostics::{DatamodelError, DatamodelWarning},
4+
ValidatedSchema,
5+
};
26

37
use crate::schema_file_input::SchemaFileInput;
48

@@ -12,33 +16,39 @@ pub struct MiniError {
1216
}
1317

1418
pub(crate) fn run(schema: SchemaFileInput) -> String {
15-
let schema = match schema {
19+
let validated_schema = match schema {
1620
SchemaFileInput::Single(file) => psl::validate(file.into()),
1721
SchemaFileInput::Multiple(files) => psl::validate_multi_file(&files),
1822
};
19-
let diagnostics = &schema.diagnostics;
23+
let ValidatedSchema { diagnostics, db, .. } = &validated_schema;
2024

2125
let mut mini_errors: Vec<MiniError> = diagnostics
2226
.errors()
2327
.iter()
24-
.map(|err: &DatamodelError| MiniError {
25-
file_name: schema.db.file_name(err.span().file_id).to_owned(),
26-
start: err.span().start,
27-
end: err.span().end,
28-
text: err.message().to_string(),
29-
is_warning: false,
28+
.map(|err: &DatamodelError| {
29+
let (start, end) = span_to_lsp_offsets(err.span(), db.source(err.span().file_id));
30+
MiniError {
31+
file_name: db.file_name(err.span().file_id).to_owned(),
32+
start,
33+
end,
34+
text: err.message().to_string(),
35+
is_warning: false,
36+
}
3037
})
3138
.collect();
3239

3340
let mut mini_warnings: Vec<MiniError> = diagnostics
3441
.warnings()
3542
.iter()
36-
.map(|warn: &DatamodelWarning| MiniError {
37-
file_name: schema.db.file_name(warn.span().file_id).to_owned(),
38-
start: warn.span().start,
39-
end: warn.span().end,
40-
text: warn.message().to_owned(),
41-
is_warning: true,
43+
.map(|warn: &DatamodelWarning| {
44+
let (start, end) = span_to_lsp_offsets(warn.span(), db.source(warn.span().file_id));
45+
MiniError {
46+
file_name: db.file_name(warn.span().file_id).to_owned(),
47+
start,
48+
end,
49+
text: warn.message().to_owned(),
50+
is_warning: true,
51+
}
4252
})
4353
.collect();
4454

@@ -64,6 +74,28 @@ mod tests {
6474
serde_json::to_string_pretty(&value).unwrap()
6575
}
6676

77+
#[test]
78+
fn should_return_utf16_offset() {
79+
let schema = indoc! {r#"
80+
// 🌐 multibyte
81+
😀
82+
"#};
83+
let datamodel = SchemaFileInput::Single(schema.to_string());
84+
85+
let expected = expect![[r#"
86+
[
87+
{
88+
"file_name": "schema.prisma",
89+
"start": 16,
90+
"end": 19,
91+
"text": "Error validating: This line is invalid. It does not start with any known Prisma schema keyword.",
92+
"is_warning": false
93+
}
94+
]"#]];
95+
96+
expected.assert_eq(&lint(datamodel));
97+
}
98+
6799
#[test]
68100
fn single_deprecated_preview_features_should_give_a_warning() {
69101
let schema = indoc! {r#"

prisma-fmt/src/main.rs

-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
mod actions;
22
mod format;
3-
// mod lint;
43
mod native;
54
mod preview;
65
mod schema_file_input;

0 commit comments

Comments
 (0)