internal/ls/lsconv/converters.go (266 lines of code) (raw):
package lsconv
import (
"context"
"fmt"
"net/url"
"slices"
"strings"
"unicode/utf16"
"unicode/utf8"
"github.com/microsoft/typescript-go/internal/ast"
"github.com/microsoft/typescript-go/internal/collections"
"github.com/microsoft/typescript-go/internal/core"
"github.com/microsoft/typescript-go/internal/diagnostics"
"github.com/microsoft/typescript-go/internal/diagnosticwriter"
"github.com/microsoft/typescript-go/internal/locale"
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
"github.com/microsoft/typescript-go/internal/tspath"
)
type Converters struct {
getLineMap func(fileName string) *LSPLineMap
positionEncoding lsproto.PositionEncodingKind
}
type Script interface {
FileName() string
Text() string
}
func NewConverters(positionEncoding lsproto.PositionEncodingKind, getLineMap func(fileName string) *LSPLineMap) *Converters {
return &Converters{
getLineMap: getLineMap,
positionEncoding: positionEncoding,
}
}
func (c *Converters) ToLSPRange(script Script, textRange core.TextRange) lsproto.Range {
return lsproto.Range{
Start: c.PositionToLineAndCharacter(script, core.TextPos(textRange.Pos())),
End: c.PositionToLineAndCharacter(script, core.TextPos(textRange.End())),
}
}
func (c *Converters) FromLSPRange(script Script, textRange lsproto.Range) core.TextRange {
return core.NewTextRange(
int(c.LineAndCharacterToPosition(script, textRange.Start)),
int(c.LineAndCharacterToPosition(script, textRange.End)),
)
}
func (c *Converters) FromLSPTextChange(script Script, change *lsproto.TextDocumentContentChangePartial) core.TextChange {
return core.TextChange{
TextRange: c.FromLSPRange(script, change.Range),
NewText: change.Text,
}
}
func (c *Converters) ToLSPLocation(script Script, rng core.TextRange) lsproto.Location {
return lsproto.Location{
Uri: FileNameToDocumentURI(script.FileName()),
Range: c.ToLSPRange(script, rng),
}
}
func LanguageKindToScriptKind(languageID lsproto.LanguageKind) core.ScriptKind {
switch languageID {
case "typescript":
return core.ScriptKindTS
case "typescriptreact":
return core.ScriptKindTSX
case "javascript":
return core.ScriptKindJS
case "javascriptreact":
return core.ScriptKindJSX
case "json":
return core.ScriptKindJSON
default:
return core.ScriptKindUnknown
}
}
// https://github.com/microsoft/vscode-uri/blob/edfdccd976efaf4bb8fdeca87e97c47257721729/src/uri.ts#L455
var extraEscapeReplacer = strings.NewReplacer(
":", "%3A",
"/", "%2F",
"?", "%3F",
"#", "%23",
"[", "%5B",
"]", "%5D",
"@", "%40",
"!", "%21",
"$", "%24",
"&", "%26",
"'", "%27",
"(", "%28",
")", "%29",
"*", "%2A",
"+", "%2B",
",", "%2C",
";", "%3B",
"=", "%3D",
" ", "%20",
)
func FileNameToDocumentURI(fileName string) lsproto.DocumentUri {
if strings.HasPrefix(fileName, "^/") {
scheme, rest, ok := strings.Cut(fileName[2:], "/")
if !ok {
panic("invalid file name: " + fileName)
}
authority, path, ok := strings.Cut(rest, "/")
if !ok {
panic("invalid file name: " + fileName)
}
if authority == "ts-nul-authority" {
return lsproto.DocumentUri(scheme + ":" + path)
}
return lsproto.DocumentUri(scheme + "://" + authority + "/" + path)
}
volume, fileName, _ := tspath.SplitVolumePath(fileName)
if volume != "" {
volume = "/" + extraEscapeReplacer.Replace(volume)
}
fileName = strings.TrimPrefix(fileName, "//")
parts := strings.Split(fileName, "/")
for i, part := range parts {
parts[i] = extraEscapeReplacer.Replace(url.PathEscape(part))
}
return lsproto.DocumentUri("file://" + volume + strings.Join(parts, "/"))
}
func (c *Converters) LineAndCharacterToPosition(script Script, lineAndCharacter lsproto.Position) core.TextPos {
// UTF-8/16 0-indexed line and character to UTF-8 offset
lineMap := c.getLineMap(script.FileName())
line := core.TextPos(lineAndCharacter.Line)
char := core.TextPos(lineAndCharacter.Character)
if line < 0 || int(line) >= len(lineMap.LineStarts) {
panic(fmt.Sprintf("bad line number. Line: %d, lineMap length: %d", line, len(lineMap.LineStarts)))
}
start := lineMap.LineStarts[line]
if lineMap.AsciiOnly || c.positionEncoding == lsproto.PositionEncodingKindUTF8 {
return start + char
}
var utf8Char core.TextPos
var utf16Char core.TextPos
for i, r := range script.Text()[start:] {
u16Len := core.TextPos(utf16.RuneLen(r))
if utf16Char+u16Len > char {
break
}
utf16Char += u16Len
utf8Char = core.TextPos(i + utf8.RuneLen(r))
}
return start + utf8Char
}
func (c *Converters) PositionToLineAndCharacter(script Script, position core.TextPos) lsproto.Position {
// UTF-8 offset to UTF-8/16 0-indexed line and character
position = min(position, core.TextPos(len(script.Text())))
lineMap := c.getLineMap(script.FileName())
line, isLineStart := slices.BinarySearch(lineMap.LineStarts, position)
if !isLineStart {
line--
}
line = max(0, line)
// The current line ranges from lineMap.LineStarts[line] (or 0) to lineMap.LineStarts[line+1] (or len(text)).
start := lineMap.LineStarts[line]
var character core.TextPos
if lineMap.AsciiOnly || c.positionEncoding == lsproto.PositionEncodingKindUTF8 {
character = position - start
} else {
// We need to rescan the text as UTF-16 to find the character offset.
for _, r := range script.Text()[start:position] {
character += core.TextPos(utf16.RuneLen(r))
}
}
return lsproto.Position{
Line: uint32(line),
Character: uint32(character),
}
}
func ptrTo[T any](v T) *T {
return &v
}
type diagnosticOptions struct {
reportStyleChecksAsWarnings bool
relatedInformation bool
tagValueSet []lsproto.DiagnosticTag
}
// DiagnosticToLSPPull converts a diagnostic for pull diagnostics (textDocument/diagnostic)
func DiagnosticToLSPPull(ctx context.Context, converters *Converters, diagnostic *ast.Diagnostic, reportStyleChecksAsWarnings bool) *lsproto.Diagnostic {
clientCaps := lsproto.GetClientCapabilities(ctx).TextDocument.Diagnostic
return diagnosticToLSP(ctx, converters, diagnostic, diagnosticOptions{
reportStyleChecksAsWarnings: reportStyleChecksAsWarnings, // !!! get through context UserPreferences
relatedInformation: clientCaps.RelatedInformation,
tagValueSet: clientCaps.TagSupport.ValueSet,
})
}
// DiagnosticToLSPPush converts a diagnostic for push diagnostics (textDocument/publishDiagnostics)
func DiagnosticToLSPPush(ctx context.Context, converters *Converters, diagnostic *ast.Diagnostic) *lsproto.Diagnostic {
clientCaps := lsproto.GetClientCapabilities(ctx).TextDocument.PublishDiagnostics
return diagnosticToLSP(ctx, converters, diagnostic, diagnosticOptions{
relatedInformation: clientCaps.RelatedInformation,
tagValueSet: clientCaps.TagSupport.ValueSet,
})
}
// https://github.com/microsoft/vscode/blob/93e08afe0469712706ca4e268f778cfadf1a43ef/extensions/typescript-language-features/src/typeScriptServiceClientHost.ts#L40C7-L40C29
var styleCheckDiagnostics = collections.NewSetFromItems(
diagnostics.X_0_is_declared_but_never_used.Code(),
diagnostics.X_0_is_declared_but_its_value_is_never_read.Code(),
diagnostics.Property_0_is_declared_but_its_value_is_never_read.Code(),
diagnostics.All_imports_in_import_declaration_are_unused.Code(),
diagnostics.Unreachable_code_detected.Code(),
diagnostics.Unused_label.Code(),
diagnostics.Fallthrough_case_in_switch.Code(),
diagnostics.Not_all_code_paths_return_a_value.Code(),
)
func diagnosticToLSP(ctx context.Context, converters *Converters, diagnostic *ast.Diagnostic, opts diagnosticOptions) *lsproto.Diagnostic {
locale := locale.FromContext(ctx)
var severity lsproto.DiagnosticSeverity
switch diagnostic.Category() {
case diagnostics.CategorySuggestion:
severity = lsproto.DiagnosticSeverityHint
case diagnostics.CategoryMessage:
severity = lsproto.DiagnosticSeverityInformation
case diagnostics.CategoryWarning:
severity = lsproto.DiagnosticSeverityWarning
default:
severity = lsproto.DiagnosticSeverityError
}
if opts.reportStyleChecksAsWarnings && severity == lsproto.DiagnosticSeverityError && styleCheckDiagnostics.Has(diagnostic.Code()) {
severity = lsproto.DiagnosticSeverityWarning
}
var relatedInformation []*lsproto.DiagnosticRelatedInformation
if opts.relatedInformation {
relatedInformation = make([]*lsproto.DiagnosticRelatedInformation, 0, len(diagnostic.RelatedInformation()))
for _, related := range diagnostic.RelatedInformation() {
relatedInformation = append(relatedInformation, &lsproto.DiagnosticRelatedInformation{
Location: lsproto.Location{
Uri: FileNameToDocumentURI(related.File().FileName()),
Range: converters.ToLSPRange(related.File(), related.Loc()),
},
Message: related.Localize(locale),
})
}
}
var tags []lsproto.DiagnosticTag
if len(opts.tagValueSet) > 0 && (diagnostic.ReportsUnnecessary() || diagnostic.ReportsDeprecated()) {
tags = make([]lsproto.DiagnosticTag, 0, 2)
if diagnostic.ReportsUnnecessary() && slices.Contains(opts.tagValueSet, lsproto.DiagnosticTagUnnecessary) {
tags = append(tags, lsproto.DiagnosticTagUnnecessary)
}
if diagnostic.ReportsDeprecated() && slices.Contains(opts.tagValueSet, lsproto.DiagnosticTagDeprecated) {
tags = append(tags, lsproto.DiagnosticTagDeprecated)
}
}
// For diagnostics without a file (e.g., program diagnostics), use a zero range
var lspRange lsproto.Range
if diagnostic.File() != nil {
lspRange = converters.ToLSPRange(diagnostic.File(), diagnostic.Loc())
}
return &lsproto.Diagnostic{
Range: lspRange,
Code: &lsproto.IntegerOrString{
Integer: ptrTo(diagnostic.Code()),
},
Severity: &severity,
Message: messageChainToString(diagnostic, locale),
Source: ptrTo("ts"),
RelatedInformation: ptrToSliceIfNonEmpty(relatedInformation),
Tags: ptrToSliceIfNonEmpty(tags),
}
}
func messageChainToString(diagnostic *ast.Diagnostic, locale locale.Locale) string {
if len(diagnostic.MessageChain()) == 0 {
return diagnostic.Localize(locale)
}
var b strings.Builder
diagnosticwriter.WriteFlattenedASTDiagnosticMessage(&b, diagnostic, "\n", locale)
return b.String()
}
func ptrToSliceIfNonEmpty[T any](s []T) *[]T {
if len(s) == 0 {
return nil
}
return &s
}