From 2f0afee71a9cd8cf1f7bd62191be6771d7d5182c Mon Sep 17 00:00:00 2001 From: Adam Snyder Date: Sun, 23 Jun 2024 04:52:50 -0700 Subject: [PATCH] feat: go to definition and find references for document refs --- .github/workflows/test.yaml | 38 + .gitignore | 2 + .golangci.yaml | 125 +++ README.md | 58 ++ generate.go | 3 + go.mod | 5 + go.sum | 2 + internal/analysis/doc.go | 2 + internal/analysis/handler.go | 134 +++ internal/analysis/handler_test.go | 236 ++++++ internal/analysis/yaml/testdata/petstore.yaml | 800 ++++++++++++++++++ internal/analysis/yaml/yaml.go | 173 ++++ internal/analysis/yaml/yaml_test.go | 355 ++++++++ internal/lsp/doc.go | 3 + internal/lsp/file.go | 117 +++ internal/lsp/file_test.go | 167 ++++ internal/lsp/handler.go | 49 ++ internal/lsp/jsonrpc/doc.go | 2 + internal/lsp/jsonrpc/jsonrpc.go | 60 ++ internal/lsp/jsonrpc/jsonrpc_test.go | 148 ++++ internal/lsp/server.go | 169 ++++ internal/lsp/server_test.go | 238 ++++++ internal/lsp/testutil/handler.go | 126 +++ internal/lsp/types/base_protocol.go | 59 ++ internal/lsp/types/base_protocol_test.go | 34 + internal/lsp/types/basic_json_structures.go | 46 + .../lsp/types/document_synchronization.go | 38 + internal/lsp/types/language_features.go | 11 + internal/lsp/types/lifecycle_messages.go | 28 + internal/lsp/utf16.go | 30 + main.go | 28 + 31 files changed, 3286 insertions(+) create mode 100644 .github/workflows/test.yaml create mode 100644 .gitignore create mode 100644 .golangci.yaml create mode 100644 README.md create mode 100644 generate.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/analysis/doc.go create mode 100644 internal/analysis/handler.go create mode 100644 internal/analysis/handler_test.go create mode 100644 internal/analysis/yaml/testdata/petstore.yaml create mode 100644 internal/analysis/yaml/yaml.go create mode 100644 internal/analysis/yaml/yaml_test.go create mode 100644 internal/lsp/doc.go create mode 100644 internal/lsp/file.go create mode 100644 internal/lsp/file_test.go create mode 100644 internal/lsp/handler.go create mode 100644 internal/lsp/jsonrpc/doc.go create mode 100644 internal/lsp/jsonrpc/jsonrpc.go create mode 100644 internal/lsp/jsonrpc/jsonrpc_test.go create mode 100644 internal/lsp/server.go create mode 100644 internal/lsp/server_test.go create mode 100644 internal/lsp/testutil/handler.go create mode 100644 internal/lsp/types/base_protocol.go create mode 100644 internal/lsp/types/base_protocol_test.go create mode 100644 internal/lsp/types/basic_json_structures.go create mode 100644 internal/lsp/types/document_synchronization.go create mode 100644 internal/lsp/types/language_features.go create mode 100644 internal/lsp/types/lifecycle_messages.go create mode 100644 internal/lsp/utf16.go create mode 100644 main.go diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..1f29d34 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,38 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow + +on: + push: + branches: + - main + pull_request: {} + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + - run: | + go test -cover ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + - uses: golangci/golangci-lint-action@v4 + with: + version: v1.59.1 + + install: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + - run: go install . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..069b93a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.out +/openapiv3-lsp diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..a9d877f --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,125 @@ +# See https://golangci-lint.run/usage/configuration/ + +linters: + disable-all: true + enable: + # See https://golangci-lint.run/usage/linters/ + - asasalint # Check for pass []any as any in variadic func(...any). + - bodyclose # Checks whether HTTP response body is closed successfully. + - contextcheck # Check whether the function uses a non-inherited context. + - durationcheck # Check for two durations multiplied together. + - errcheck # Checks whether Rows.Err of rows is checked successfully. + - errchkjson # Checks types passed to the json encoding functions. Reports unsupported types and reports occations, where the check for the returned error can be omitted. + - errorlint # Errorlint is a linter for that can be used to find code that will cause problems with the error wrapping scheme introduced in Go 1.13. + - forbidigo # Forbids identifiers. + - gci # Gci controls Go package import order and makes it always deterministic. + - gocritic # Provides diagnostics that check for bugs, performance and style issues. Extensible without recompilation through dynamic rules. Dynamic rules are written declaratively with AST patterns, filters, report message and optional suggestion. + - godot # Check if comments end in a period. + - gosec # Inspects source code for security problems. + - gosimple # Linter for Go source code that specializes in simplifying code. + - govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string. + - inamedparam # Reports interfaces with unnamed method parameters. + - ineffassign # Detects when assignments to existing variables are not used. + - mirror # Reports wrong mirror patterns of bytes/strings usage. + - musttag # Enforce field tags in (un)marshaled structs. + - nilerr # Finds the code that returns nil even if it checks that the error is not nil. + - nilnil # Checks that there is no simultaneous return of nil error and an invalid value. + - noctx # Finds sending http request without context.Context. + - nolintlint # Reports ill-formed or insufficient nolint directives. + - nosprintfhostport # Checks for misuse of Sprintf to construct a host with port in a URL. + - perfsprint # Checks that fmt.Sprintf can be replaced with a faster alternative. + - protogetter # Reports direct reads from proto message fields when getters should be used. + - reassign # Checks that package variables are not reassigned. + - revive # Fast, configurable, extensible, flexible, and beautiful linter for Go. Drop-in replacement of golint. + - staticcheck # It's a set of rules from staticcheck. It's not the same thing as the staticcheck binary. The author of staticcheck doesn't support or approve the use of staticcheck as a library inside golangci-lint. + - tenv # # Tenv is analyzer that detects using os.Setenv instead of t.Setenv since Go1.17. + - unconvert # Remove unnecessary type conversions. + - unused # Checks Go code for unused constants, variables, functions and types. + +linters-settings: + # See https://golangci-lint.run/usage/linters/#linters-configuration + forbidigo: + forbid: + - 'fmt\.Print.*' # Should be using a logger + gci: + sections: + - standard + - default + - prefix(github.com/armsnyder) + gocritic: + enabled-tags: + - performance + - opinionated + - experimental + disabled-checks: + - whyNoLint # False positives, use nolintlint instead + govet: + enable-all: true + disable: + - fieldalignment # Too struct + nolintlint: + require-specific: true + revive: + enable-all-rules: true + rules: + # See https://revive.run/r + - name: add-constant # too strict + disabled: true + - name: argument-limit # too strict + disabled: true + - name: cognitive-complexity + arguments: + - 30 + - name: cyclomatic + arguments: + - 30 + - name: file-header # too strict + disabled: true + - name: function-length + arguments: + - 50 # statements + - 0 # lines (0 to disable) + - name: function-result-limit # too strict + disabled: true + - name: import-shadowing # too strict, results in uglier code + disabled: true + - name: line-length-limit # too strict + disabled: true + - name: max-public-structs # too strict + disabled: true + - name: modifies-parameter # too strict + disabled: true + - name: modifies-value-receiver # too strict + disabled: true + - name: nested-structs # too strict + disabled: true + - name: package-comments # too strict + disabled: true + - name: unhandled-error + disabled: true # not as good as errcheck + +issues: + exclude-rules: + - path: _test\.go$ + linters: + - gosec # too strict + - noctx # too strict + - path: _test\.go$ + text: (cognitive-complexity|function-length|dot-imports|import-alias-naming) # too strict + linters: + - revive + # main.go is allowed to contain early bootstrapping print statements. + # TestMain is allowed to log. + - path: \/main(_test)?\.go$ + text: fmt.Print + linters: + - forbidigo + # Shadowing err is common. + - text: 'shadow: declaration of "err"' + linters: + - govet + - text: "^exported:.+stutters" # too strict and gets in the way of combining types like handlers + linters: + - revive + - path: _test\.go$ + text: "unused-parameter" # too strict diff --git a/README.md b/README.md new file mode 100644 index 0000000..7293ddd --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# OpenAPI Language Server + +This is a language server for OpenAPI v3.0.0. It is based on the [Language +Server Protocol](https://microsoft.github.io/language-server-protocol/). + +I created this language server because I do a lot of manual OpenAPI/Swagger +file editing, and I wanted a quick way to jump to definitions and find +references of schema definitions. + +I personally use +[yaml-language-server](https://github.com/redhat-developer/yaml-language-server) +for schema validation and code completion, so these features are not a priority +for me to implement in this language server. + +## Features + +### Language Features + +- [x] Jump to definition +- [x] Find references +- [ ] Code completion +- [ ] Diagnostics +- [ ] Hover +- [ ] Rename +- [ ] Document symbols +- [ ] Code actions + +### Other Features + +- [x] YAML filetype support +- [ ] JSON filetype support +- [ ] VSCode extension + +## Installation + +```bash +go install github.com/armsnyder/openapiv3-lsp@latest +``` + +### Neovim Configuration Example + +Assuming you are using Neovim and have the installed openapiv3-lsp binary in +your PATH, you can use the following Lua code to your Neovim configuration: + +```lua + vim.api.nvim_create_autocmd('FileType', { + pattern = 'yaml', + callback = function() + vim.lsp.start { + cmd = { 'openapiv3-lsp' }, + filetypes = { 'yaml' }, + } + end, + }) +``` + +This is just a basic working example. You will probably want to further +customize the configuration to your needs. diff --git a/generate.go b/generate.go new file mode 100644 index 0000000..ccefc63 --- /dev/null +++ b/generate.go @@ -0,0 +1,3 @@ +package main + +//go:generate go run go.uber.org/mock/mockgen@v0.4.0 -source internal/lsp/handler.go -destination internal/lsp/testutil/handler.go -package testutil diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..10c1a04 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/armsnyder/openapiv3-lsp + +go 1.22.4 + +require go.uber.org/mock v0.4.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..450b350 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= diff --git a/internal/analysis/doc.go b/internal/analysis/doc.go new file mode 100644 index 0000000..15e6765 --- /dev/null +++ b/internal/analysis/doc.go @@ -0,0 +1,2 @@ +// Package anaylsis contains the OpenAPI Lanuage Server business logic. +package analysis diff --git a/internal/analysis/handler.go b/internal/analysis/handler.go new file mode 100644 index 0000000..60c21d7 --- /dev/null +++ b/internal/analysis/handler.go @@ -0,0 +1,134 @@ +package analysis + +import ( + "bytes" + "fmt" + + "github.com/armsnyder/openapiv3-lsp/internal/analysis/yaml" + "github.com/armsnyder/openapiv3-lsp/internal/lsp" + "github.com/armsnyder/openapiv3-lsp/internal/lsp/types" +) + +// Handler implements the LSP handler for the OpenAPI Language Server. It +// contains the business logic for the server. +type Handler struct { + lsp.NopHandler + + files map[string]*annotatedFile +} + +type annotatedFile struct { + file lsp.File + document yaml.Document +} + +func (h *Handler) getDocument(uri string) (yaml.Document, error) { + f := h.files[uri] + if f == nil { + return yaml.Document{}, fmt.Errorf("unknown file: %s", uri) + } + + if f.document.Lines == nil { + document, err := yaml.Parse(bytes.NewReader(f.file.Bytes())) + if err != nil { + return yaml.Document{}, err + } + f.document = document + } + + return f.document, nil +} + +func (*Handler) Capabilities() types.ServerCapabilities { + return types.ServerCapabilities{ + TextDocumentSync: types.TextDocumentSyncOptions{ + OpenClose: true, + Change: types.SyncIncremental, + }, + DefinitionProvider: true, + ReferencesProvider: true, + } +} + +func (h *Handler) HandleOpen(params types.DidOpenTextDocumentParams) error { + if h.files == nil { + h.files = make(map[string]*annotatedFile) + } + + var f annotatedFile + + f.file.Reset([]byte(params.TextDocument.Text)) + h.files[params.TextDocument.URI] = &f + + return nil +} + +func (h *Handler) HandleClose(params types.DidCloseTextDocumentParams) error { + delete(h.files, params.TextDocument.URI) + return nil +} + +func (h *Handler) HandleChange(params types.DidChangeTextDocumentParams) error { + f, ok := h.files[params.TextDocument.URI] + if !ok { + return fmt.Errorf("unknown file: %s", params.TextDocument.URI) + } + + for _, change := range params.ContentChanges { + if err := f.file.ApplyChange(change); err != nil { + return err + } + } + + return nil +} + +func (h *Handler) HandleDefinition(params types.DefinitionParams) ([]types.Location, error) { + document, err := h.getDocument(params.TextDocument.URI) + if err != nil { + return nil, err + } + + if params.Position.Line >= len(document.Lines) { + return nil, nil + } + + ref := document.Lines[params.Position.Line].Value + + referencedLine := document.Locate(ref) + if referencedLine == nil { + return nil, nil + } + + return []types.Location{{ + URI: params.TextDocument.URI, + Range: referencedLine.KeyRange, + }}, nil +} + +func (h *Handler) HandleReferences(params types.ReferenceParams) ([]types.Location, error) { + document, err := h.getDocument(params.TextDocument.URI) + if err != nil { + return nil, err + } + + if params.Position.Line >= len(document.Lines) { + return nil, nil + } + + ref := document.Lines[params.Position.Line].KeyRef() + + var locations []types.Location + + for _, line := range document.Lines { + if line.Value == ref { + locations = append(locations, types.Location{ + URI: params.TextDocument.URI, + Range: line.ValueRange, + }) + } + } + return locations, nil +} + +var _ lsp.Handler = (*Handler)(nil) diff --git a/internal/analysis/handler_test.go b/internal/analysis/handler_test.go new file mode 100644 index 0000000..8e2814d --- /dev/null +++ b/internal/analysis/handler_test.go @@ -0,0 +1,236 @@ +package analysis_test + +import ( + "reflect" + "regexp" + "strconv" + "strings" + "testing" + + . "github.com/armsnyder/openapiv3-lsp/internal/analysis" + "github.com/armsnyder/openapiv3-lsp/internal/lsp/types" +) + +type HandlerSetupFunc func(t *testing.T, h *Handler) + +func TestHandler_HandleDefinition(t *testing.T) { + tests := []struct { + name string + setup HandlerSetupFunc + params types.DefinitionParams + want []types.Location + wantErr bool + }{ + { + name: "file not found", + setup: loadFile("file:///foo", "foo"), + params: definitionParams("file:///bar", "0:0"), + wantErr: true, + }, + { + name: "no definition", + setup: loadFile("file:///foo", "foo"), + params: definitionParams("file:///foo", "0:0"), + want: nil, + }, + { + name: "start of ref", + setup: loadFile("file:///foo", ` +foo: + $ref: "#/bar/baz" +bar: + baz: + type: object`), + params: definitionParams("file:///foo", "2:8"), + want: locations("file:///foo", "4:2-4:5"), + }, + { + name: "end of ref", + setup: loadFile("file:///foo", ` +foo: + $ref: "#/bar/baz" +bar: + baz: + type: object`), + params: definitionParams("file:///foo", "2:18"), + want: locations("file:///foo", "4:2-4:5"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var h Handler + + tt.setup(t, &h) + + got, err := h.HandleDefinition(tt.params) + + if (err != nil) != tt.wantErr { + t.Errorf("HandleDefinition() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.want == nil && got == nil { + return + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("HandleDefinition() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHandler_HandleReferences(t *testing.T) { + tests := []struct { + name string + setup HandlerSetupFunc + params types.ReferenceParams + want []types.Location + wantErr bool + }{ + { + name: "file not found", + setup: loadFile("file:///foo", "foo"), + params: referenceParams("file:///bar", "0:0"), + wantErr: true, + }, + { + name: "no references", + setup: loadFile("file:///foo", "foo"), + params: referenceParams("file:///foo", "0:0"), + want: nil, + }, + { + name: "simple", + setup: loadFile("file:///foo", ` +foo: + $ref: "#/bar/baz" +bar: + baz: + type: object`), + params: referenceParams("file:///foo", "4:2"), + want: locations("file:///foo", "2:9-2:18"), + }, + { + name: "multiple references", + setup: loadFile("file:///foo", ` +foo: + $ref: "#/bar/baz" +foo2: + $ref: "#/bar/baz" +bar: + baz: + type: object`), + params: referenceParams("file:///foo", "6:2"), + want: locations("file:///foo", "2:9-2:18", "4:9-4:18"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var h Handler + + tt.setup(t, &h) + + got, err := h.HandleReferences(tt.params) + + if (err != nil) != tt.wantErr { + t.Errorf("HandleReferences() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if len(tt.want) == 0 && len(got) == 0 { + return + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("HandleReferences() = %v, want %v", got, tt.want) + } + }) + } +} + +func loadFile(uri, text string) HandlerSetupFunc { + return func(t *testing.T, h *Handler) { + if err := h.HandleOpen(types.DidOpenTextDocumentParams{ + TextDocument: types.TextDocumentItem{ + URI: uri, + Text: text, + }, + }); err != nil { + t.Fatal(err) + } + } +} + +func referenceParams(uri, position string) types.ReferenceParams { + return types.ReferenceParams{ + TextDocumentPositionParams: positionParams(uri, position), + } +} + +func definitionParams(uri, position string) types.DefinitionParams { + return types.DefinitionParams{ + TextDocumentPositionParams: positionParams(uri, position), + } +} + +func positionParams(uri, position string) types.TextDocumentPositionParams { + split := strings.Split(position, ":") + + line, err := strconv.Atoi(split[0]) + if err != nil { + panic(err) + } + + character, err := strconv.Atoi(split[1]) + if err != nil { + panic(err) + } + + return types.TextDocumentPositionParams{ + TextDocument: types.TextDocumentIdentifier{ + URI: uri, + }, + Position: types.Position{ + Line: line, + Character: character, + }, + } +} + +func locations(uri string, ranges ...string) []types.Location { + pat := regexp.MustCompile(`^(\d+):(\d+)-(\d+):(\d+)$`) + locs := make([]types.Location, len(ranges)) + for i, rng := range ranges { + match := pat.FindStringSubmatch(rng) + if match == nil { + panic("invalid range") + } + + locs[i] = types.Location{ + URI: uri, + Range: types.Range{ + Start: types.Position{ + Line: mustAtoi(match[1]), + Character: mustAtoi(match[2]), + }, + End: types.Position{ + Line: mustAtoi(match[3]), + Character: mustAtoi(match[4]), + }, + }, + } + } + + return locs +} + +func mustAtoi(s string) int { + i, err := strconv.Atoi(s) + if err != nil { + panic(err) + } + return i +} diff --git a/internal/analysis/yaml/testdata/petstore.yaml b/internal/analysis/yaml/testdata/petstore.yaml new file mode 100644 index 0000000..bc810a6 --- /dev/null +++ b/internal/analysis/yaml/testdata/petstore.yaml @@ -0,0 +1,800 @@ +openapi: 3.0.2 +info: + title: Swagger Petstore - OpenAPI 3.0 + description: |- + This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about + Swagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach! + You can now help us improve the API whether it's by making changes to the definition itself or to the code. + That way, with time, we can improve the API in general, and expose some of the new features in OAS3. + + Some useful links: + - [The Pet Store repository](https://github.com/swagger-api/swagger-petstore) + - [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml) + termsOfService: http://swagger.io/terms/ + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.19 +externalDocs: + description: Find out more about Swagger + url: http://swagger.io +servers: + - url: /api/v3 +tags: + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: http://swagger.io + - name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: http://swagger.io + - name: user + description: Operations about user +paths: + /pet: + put: + tags: + - pet + summary: Update an existing pet + description: Update an existing pet by Id + operationId: updatePet + requestBody: + description: Update an existent pet in the store + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + application/xml: + schema: + $ref: "#/components/schemas/Pet" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Pet" + required: true + responses: + "200": + description: Successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/Pet" + application/json: + schema: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid ID supplied + "404": + description: Pet not found + "405": + description: Validation exception + security: + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Add a new pet to the store + description: Add a new pet to the store + operationId: addPet + requestBody: + description: Create a new pet in the store + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + application/xml: + schema: + $ref: "#/components/schemas/Pet" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Pet" + required: true + responses: + "200": + description: Successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/Pet" + application/json: + schema: + $ref: "#/components/schemas/Pet" + "405": + description: Invalid input + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: false + explode: true + schema: + type: string + default: available + enum: + - available + - pending + - sold + responses: + "200": + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid status value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags + description: Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: false + explode: true + schema: + type: array + items: + type: string + responses: + "200": + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid tag value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: + tags: + - pet + summary: Find pet by ID + description: Returns a single pet + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/Pet" + application/json: + schema: + $ref: "#/components/schemas/Pet" + "400": + description: Invalid ID supplied + "404": + description: Pet not found + security: + - api_key: [] + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Updates a pet in the store with form data + description: "" + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + - name: name + in: query + description: Name of pet that needs to be updated + schema: + type: string + - name: status + in: query + description: Status of pet that needs to be updated + schema: + type: string + responses: + "405": + description: Invalid input + security: + - petstore_auth: + - write:pets + - read:pets + delete: + tags: + - pet + summary: Deletes a pet + description: "" + operationId: deletePet + parameters: + - name: api_key + in: header + description: "" + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + "400": + description: Invalid pet value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: + tags: + - pet + summary: uploads an image + description: "" + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + description: Additional Metadata + required: false + schema: + type: string + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/ApiResponse" + security: + - petstore_auth: + - write:pets + - read:pets + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + operationId: getInventory + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet + description: Place a new order in the store + operationId: placeOrder + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + application/xml: + schema: + $ref: "#/components/schemas/Order" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/Order" + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/Order" + "405": + description: Invalid input + /store/order/{orderId}: + get: + tags: + - store + summary: Find purchase order by ID + description: For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions. + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of order that needs to be fetched + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/Order" + application/json: + schema: + $ref: "#/components/schemas/Order" + "400": + description: Invalid ID supplied + "404": + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by ID + description: For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: integer + format: int64 + responses: + "400": + description: Invalid ID supplied + "404": + description: Order not found + /user: + post: + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: createUser + requestBody: + description: Created user object + content: + application/json: + schema: + $ref: "#/components/schemas/User" + application/xml: + schema: + $ref: "#/components/schemas/User" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/User" + responses: + default: + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/User" + application/xml: + schema: + $ref: "#/components/schemas/User" + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array + description: Creates list of users with given input array + operationId: createUsersWithListInput + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + responses: + "200": + description: Successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/User" + application/json: + schema: + $ref: "#/components/schemas/User" + default: + description: successful operation + /user/login: + get: + tags: + - user + summary: Logs user into the system + description: "" + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: false + schema: + type: string + - name: password + in: query + description: The password for login in clear text + required: false + schema: + type: string + responses: + "200": + description: successful operation + headers: + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + content: + application/xml: + schema: + type: string + application/json: + schema: + type: string + "400": + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session + description: "" + operationId: logoutUser + parameters: [] + responses: + default: + description: successful operation + /user/{username}: + get: + tags: + - user + summary: Get user by user name + description: "" + operationId: getUserByName + parameters: + - name: username + in: path + description: "The name that needs to be fetched. Use user1 for testing. " + required: true + schema: + type: string + responses: + "200": + description: successful operation + content: + application/xml: + schema: + $ref: "#/components/schemas/User" + application/json: + schema: + $ref: "#/components/schemas/User" + "400": + description: Invalid username supplied + "404": + description: User not found + put: + tags: + - user + summary: Update user + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: name that needs to be updated + required: true + schema: + type: string + requestBody: + description: Update an existent user in the store + content: + application/json: + schema: + $ref: "#/components/schemas/User" + application/xml: + schema: + $ref: "#/components/schemas/User" + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/User" + responses: + default: + description: successful operation + delete: + tags: + - user + summary: Delete user + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + "400": + description: Invalid username supplied + "404": + description: User not found +components: + schemas: + Order: + type: object + properties: + id: + type: integer + format: int64 + example: 10 + petId: + type: integer + format: int64 + example: 198772 + quantity: + type: integer + format: int32 + example: 7 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + example: approved + enum: + - placed + - approved + - delivered + complete: + type: boolean + xml: + name: order + Customer: + type: object + properties: + id: + type: integer + format: int64 + example: 100000 + username: + type: string + example: fehguy + address: + type: array + xml: + name: addresses + wrapped: true + items: + $ref: "#/components/schemas/Address" + xml: + name: customer + Address: + type: object + properties: + street: + type: string + example: 437 Lytton + city: + type: string + example: Palo Alto + state: + type: string + example: CA + zip: + type: string + example: "94301" + xml: + name: address + Category: + type: object + properties: + id: + type: integer + format: int64 + example: 1 + name: + type: string + example: Dogs + xml: + name: category + User: + type: object + properties: + id: + type: integer + format: int64 + example: 10 + username: + type: string + example: theUser + firstName: + type: string + example: John + lastName: + type: string + example: James + email: + type: string + example: john@email.com + password: + type: string + example: "12345" + phone: + type: string + example: "12345" + userStatus: + type: integer + description: User Status + format: int32 + example: 1 + xml: + name: user + Tag: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: tag + Pet: + required: + - name + - photoUrls + type: object + properties: + id: + type: integer + format: int64 + example: 10 + name: + type: string + example: doggie + category: + $ref: "#/components/schemas/Category" + photoUrls: + type: array + xml: + wrapped: true + items: + type: string + xml: + name: photoUrl + tags: + type: array + xml: + wrapped: true + items: + $ref: "#/components/schemas/Tag" + status: + type: string + description: pet status in the store + enum: + - available + - pending + - sold + xml: + name: pet + ApiResponse: + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + xml: + name: "##default" + requestBodies: + Pet: + description: Pet object that needs to be added to the store + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + application/xml: + schema: + $ref: "#/components/schemas/Pet" + UserArray: + description: List of user object + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://petstore3.swagger.io/oauth/authorize + scopes: + write:pets: modify pets in your account + read:pets: read your pets + api_key: + type: apiKey + name: api_key + in: header diff --git a/internal/analysis/yaml/yaml.go b/internal/analysis/yaml/yaml.go new file mode 100644 index 0000000..88e6bfe --- /dev/null +++ b/internal/analysis/yaml/yaml.go @@ -0,0 +1,173 @@ +package yaml + +import ( + "bufio" + "bytes" + "io" + "strings" + + "github.com/armsnyder/openapiv3-lsp/internal/lsp/types" +) + +// Document represents a YAML document. +type Document struct { + Lines []*Line + Root map[string]*Line +} + +// Locate finds a line in the document by its JSON reference URI. +func (s Document) Locate(ref string) *Line { + split := strings.Split(ref, "/") + if len(split) < 2 { + return nil + } + + cur := s.Root[split[1]] + if cur == nil { + return nil + } + + for _, key := range split[2:] { + cur = cur.Children[key] + if cur == nil { + return nil + } + } + + return cur +} + +// Line represents a line in a YAML document. +type Line struct { + Parent *Line + Children map[string]*Line + Key string + Value string + KeyRange types.Range + ValueRange types.Range +} + +// KeyRef returns the JSON reference URI that describes the key on this line. +func (e *Line) KeyRef() string { + keys := []string{} + + for cur := e; cur != nil; cur = cur.Parent { + keys = append(keys, cur.Key) + } + + var b strings.Builder + + b.WriteString("#/") + + for i := len(keys) - 1; i >= 0; i-- { + b.WriteString(keys[i]) + + if i > 0 { + b.WriteByte('/') + } + } + + return b.String() +} + +type lineWithIndent struct { + line *Line + indent int +} + +// Parse parses a YAML document from a reader, using best-effort. The YAML does +// not need to be syntactically valid. +func Parse(r io.Reader) (Document, error) { + parentStack := []lineWithIndent{} + scanner := bufio.NewScanner(r) + document := Document{ + Root: map[string]*Line{}, + } + + for lineNum := 0; scanner.Scan(); lineNum++ { + line := parseLine(scanner.Bytes(), lineNum) + document.Lines = append(document.Lines, line.line) + + for len(parentStack) > 0 && parentStack[len(parentStack)-1].indent >= line.indent { + parentStack = parentStack[:len(parentStack)-1] + } + + if len(parentStack) == 0 { + document.Root[line.line.Key] = line.line + parentStack = append(parentStack, line) + continue + } + + parent := parentStack[len(parentStack)-1] + line.line.Parent = parent.line + if parent.line.Children == nil { + parent.line.Children = map[string]*Line{} + } + parent.line.Children[line.line.Key] = line.line + parentStack = append(parentStack, line) + } + + if err := scanner.Err(); err != nil { + return Document{}, err + } + + return document, nil +} + +func parseLine(s []byte, lineNum int) lineWithIndent { + result := lineWithIndent{ + line: &Line{}, + } + + result.indent = bytes.IndexFunc(s, func(ch rune) bool { + return ch != ' ' + }) + if result.indent == -1 { + result.indent = len(s) + } + + keyEnd := bytes.Index(s, []byte(":")) + if keyEnd == -1 { + return result + } + + result.line.Key = string(s[result.indent:keyEnd]) + result.line.KeyRange = types.Range{ + Start: types.Position{Line: lineNum, Character: result.indent}, + End: types.Position{Line: lineNum, Character: keyEnd}, + } + + valueStart := bytes.IndexFunc(s[keyEnd+1:], func(ch rune) bool { + return ch != ' ' + }) + if valueStart == -1 { + return result + } + valueStart += keyEnd + 1 + if valueStart >= len(s) { + return result + } + + if s[valueStart] == '"' || s[valueStart] == '\'' { + valueEnd := bytes.LastIndex(s, s[valueStart:valueStart+1]) + if valueEnd <= valueStart { + return result + } + + result.line.Value = string(s[valueStart+1 : valueEnd]) + result.line.ValueRange = types.Range{ + Start: types.Position{Line: lineNum, Character: valueStart + 1}, + End: types.Position{Line: lineNum, Character: valueEnd}, + } + + return result + } + + result.line.Value = string(s[valueStart:]) + result.line.ValueRange = types.Range{ + Start: types.Position{Line: lineNum, Character: valueStart}, + End: types.Position{Line: lineNum, Character: len(s)}, + } + + return result +} diff --git a/internal/analysis/yaml/yaml_test.go b/internal/analysis/yaml/yaml_test.go new file mode 100644 index 0000000..2d25aa7 --- /dev/null +++ b/internal/analysis/yaml/yaml_test.go @@ -0,0 +1,355 @@ +package yaml_test + +import ( + "bytes" + "os" + "strconv" + "testing" + + . "github.com/armsnyder/openapiv3-lsp/internal/analysis/yaml" + "github.com/armsnyder/openapiv3-lsp/internal/lsp/types" +) + +func TestParseLine(t *testing.T) { + tests := []struct { + name string + line string + want Line + }{ + { + name: "empty", + line: "", + want: Line{}, + }, + { + name: "key only", + line: "foo:", + want: Line{ + Key: "foo", + KeyRange: types.Range{ + Start: types.Position{Line: 0, Character: 0}, + End: types.Position{Line: 0, Character: 3}, + }, + }, + }, + { + name: "key and value", + line: "foo: bar", + want: Line{ + Key: "foo", + Value: "bar", + KeyRange: types.Range{ + Start: types.Position{Line: 0, Character: 0}, + End: types.Position{Line: 0, Character: 3}, + }, + ValueRange: types.Range{ + Start: types.Position{Line: 0, Character: 5}, + End: types.Position{Line: 0, Character: 8}, + }, + }, + }, + { + name: "key and value with leading whitespace", + line: " foo: bar", + want: Line{ + Key: "foo", + Value: "bar", + KeyRange: types.Range{ + Start: types.Position{Line: 0, Character: 2}, + End: types.Position{Line: 0, Character: 5}, + }, + ValueRange: types.Range{ + Start: types.Position{Line: 0, Character: 7}, + End: types.Position{Line: 0, Character: 10}, + }, + }, + }, + { + name: "double quoted value", + line: `foo: "bar"`, + want: Line{ + Key: "foo", + Value: "bar", + KeyRange: types.Range{ + Start: types.Position{Line: 0, Character: 0}, + End: types.Position{Line: 0, Character: 3}, + }, + ValueRange: types.Range{ + Start: types.Position{Line: 0, Character: 6}, + End: types.Position{Line: 0, Character: 9}, + }, + }, + }, + { + name: "single quoted value", + line: `foo: 'bar'`, + want: Line{ + Key: "foo", + Value: "bar", + KeyRange: types.Range{ + Start: types.Position{Line: 0, Character: 0}, + End: types.Position{Line: 0, Character: 3}, + }, + ValueRange: types.Range{ + Start: types.Position{Line: 0, Character: 6}, + End: types.Position{Line: 0, Character: 9}, + }, + }, + }, + { + name: "extra space before value", + line: "foo: bar", + want: Line{ + Key: "foo", + Value: "bar", + KeyRange: types.Range{ + Start: types.Position{Line: 0, Character: 0}, + End: types.Position{Line: 0, Character: 3}, + }, + ValueRange: types.Range{ + Start: types.Position{Line: 0, Character: 6}, + End: types.Position{Line: 0, Character: 9}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + document, err := Parse(bytes.NewReader([]byte(tt.line + "\n"))) + if err != nil { + t.Fatal(err) + } + if len(document.Lines) != 1 { + t.Fatalf("got %d lines, want 1", len(document.Lines)) + } + line := document.Lines[0] + if line.Key != tt.want.Key { + t.Errorf("got key %q, want %q", line.Key, tt.want.Key) + } + if line.Value != tt.want.Value { + t.Errorf("got value %q, want %q", line.Value, tt.want.Value) + } + if len(line.Children) > 0 { + t.Errorf("got %d children, want 0", len(line.Children)) + } + if line.Parent != nil { + t.Errorf("got parent, want nil") + } + if line.KeyRange != tt.want.KeyRange { + t.Errorf("got key range %v, want %v", line.KeyRange, tt.want.KeyRange) + } + if line.ValueRange != tt.want.ValueRange { + t.Errorf("got value range %v, want %v", line.ValueRange, tt.want.ValueRange) + } + }) + } +} + +func TestParse(t *testing.T) { + t.Run("empty", func(t *testing.T) { + document, err := Parse(bytes.NewReader([]byte(""))) + if err != nil { + t.Fatal(err) + } + + if len(document.Lines) != 0 { + t.Errorf("got %d lines, want 0", len(document.Lines)) + } + + if len(document.Root) != 0 { + t.Errorf("got %d root keys, want 0", len(document.Root)) + } + }) + + t.Run("one line", func(t *testing.T) { + document, err := Parse(bytes.NewReader([]byte("foo: bar\n"))) + if err != nil { + t.Fatal(err) + } + + if len(document.Lines) != 1 { + t.Errorf("got %d lines, want 1", len(document.Lines)) + } + + if len(document.Root) != 1 { + t.Errorf("got %d root keys, want 1", len(document.Root)) + } + + line := document.Lines[0] + if line.Key != "foo" { + t.Errorf("got key %q, want %q", line.Key, "foo") + } + + if line.Value != "bar" { + t.Errorf("got value %q, want %q", line.Value, "bar") + } + + if len(line.Children) != 0 { + t.Errorf("got %d children, want 0", len(line.Children)) + } + + if line.Parent != nil { + t.Errorf("got parent, want nil") + } + + if document.Root["foo"] != line { + t.Errorf("root key and line do not match") + } + }) + + t.Run("nested", func(t *testing.T) { + document, err := Parse(bytes.NewReader([]byte("foo:\n bar: baz\n"))) + if err != nil { + t.Fatal(err) + } + + if len(document.Lines) != 2 { + t.Errorf("got %d lines, want 2", len(document.Lines)) + } + + if len(document.Root) != 1 { + t.Errorf("got %d root keys, want 1", len(document.Root)) + } + + foo := document.Root["foo"] + if foo == nil { + t.Fatal("missing root key foo") + } + + if foo.Key != "foo" { + t.Errorf("foo: got key %q, want %q", foo.Key, "foo") + } + + if foo.Value != "" { + t.Errorf("foo: got value %q, want %q", foo.Value, "") + } + + if len(foo.Children) != 1 { + t.Errorf("foo: got %d children, want 1", len(foo.Children)) + } + + if foo.Parent != nil { + t.Errorf("foo: got parent, want nil") + } + + bar := foo.Children["bar"] + if bar == nil { + t.Fatal("foo: missing child key bar") + } + + if bar.Key != "bar" { + t.Errorf("bar: got key %q, want %q", bar.Key, "bar") + } + + if bar.Value != "baz" { + t.Errorf("bar: got value %q, want %q", bar.Value, "baz") + } + + if len(bar.Children) != 0 { + t.Errorf("bar: got %d children, want 0", len(bar.Children)) + } + + if bar.Parent != foo { + t.Errorf("bar: parent mismatch") + } + + if foo != document.Lines[0] { + t.Errorf("foo line does not match") + } + + if bar != document.Lines[1] { + t.Errorf("bar line does not match") + } + }) +} + +func TestRefs(t *testing.T) { + document, err := Parse(bytes.NewReader([]byte(` +# comment +openapi: 3.0.0 +paths: + /foo: + get: + $ref: "#/components/schemas/Foo" +components: + schemas: + Foo: + type: object + Bar: + type: object +`))) + if err != nil { + t.Fatal(err) + } + + const undefined = "undefined" + + expectedRefs := []string{ + undefined, + undefined, + "#/openapi", + "#/paths", + undefined, + undefined, + undefined, + "#/components", + "#/components/schemas", + "#/components/schemas/Foo", + "#/components/schemas/Foo/type", + "#/components/schemas/Bar", + "#/components/schemas/Bar/type", + } + + for i, expectedRef := range expectedRefs { + if expectedRef == undefined { + continue + } + + t.Run(strconv.Itoa(i), func(t *testing.T) { + line := document.Lines[i] + gotRef := line.KeyRef() + if gotRef != expectedRef { + t.Errorf("KeyRef: got %q, want %q", gotRef, expectedRef) + } + + if expectedRef != "" { + loc := document.Locate(expectedRef) + if loc != line { + t.Errorf("Locate: got %v, want %v", loc, line) + } + } + }) + } +} + +func TestParse_PetStore(t *testing.T) { + f, err := os.Open("testdata/petstore.yaml") + if err != nil { + t.Fatal(err) + } + defer f.Close() + + document, err := Parse(f) + if err != nil { + t.Fatal(err) + } + + line := document.Locate("#/components/schemas/Pet") + if line == nil { + t.Fatal("could not locate Pet schema") + } + + wantRange := types.Range{ + Start: types.Position{Line: 719, Character: 4}, + End: types.Position{Line: 719, Character: 7}, + } + if line.KeyRange != wantRange { + t.Errorf("got key range %v, want %v", line.KeyRange, wantRange) + } + + wantKey := "Pet" + if line.Key != wantKey { + t.Errorf("got key %q, want %q", line.Key, wantKey) + } +} diff --git a/internal/lsp/doc.go b/internal/lsp/doc.go new file mode 100644 index 0000000..992c34b --- /dev/null +++ b/internal/lsp/doc.go @@ -0,0 +1,3 @@ +// Package lsp provides a pluggable language server protocol implementation, as +// well as utilities to make building language servers easier. +package lsp diff --git a/internal/lsp/file.go b/internal/lsp/file.go new file mode 100644 index 0000000..e0d513b --- /dev/null +++ b/internal/lsp/file.go @@ -0,0 +1,117 @@ +package lsp + +import ( + "bytes" + "errors" + "fmt" + "slices" + "unicode/utf8" + + "github.com/armsnyder/openapiv3-lsp/internal/lsp/types" +) + +// File is a representation of a text file that can be modified by LSP text +// document change events. It keeps track of line breaks to allow for efficient +// conversion between byte offsets and LSP positions. +type File struct { + bytes []byte + lineOffsets []int +} + +// Bytes returns the raw bytes of the file. +func (f File) Bytes() []byte { + return f.bytes +} + +// Reset initializes the file with the given content. +func (f *File) Reset(s []byte) { + f.bytes = s + newlineCount := bytes.Count(f.bytes, []byte{'\n'}) + f.lineOffsets = make([]int, 1, newlineCount+1) + + for i, b := range f.bytes { + if b == '\n' { + f.lineOffsets = append(f.lineOffsets, i+1) + } + } +} + +// ApplyChange applies the given change to the file content. +func (f *File) ApplyChange(change types.TextDocumentContentChangeEvent) error { + if change.Range == nil { + f.Reset([]byte(change.Text)) + return nil + } + + start, err := f.GetOffset(change.Range.Start) + if err != nil { + return err + } + + end, err := f.GetOffset(change.Range.End) + if err != nil { + return err + } + + f.Reset(append(f.bytes[:start], append([]byte(change.Text), f.bytes[end:]...)...)) + + return nil +} + +// GetPosition returns the LSP protocol position for the given byte offset. +func (f *File) GetPosition(offset int) (types.Position, error) { + if offset < 0 || offset > len(f.bytes) { + return types.Position{}, fmt.Errorf("offset %d is out of range [0, %d]", offset, len(f.bytes)) + } + + line, found := slices.BinarySearch(f.lineOffsets, offset) + if !found { + line-- + } + + character := UTF16Len(f.bytes[f.lineOffsets[line]:offset]) + + return types.Position{Line: line, Character: character}, nil +} + +// GetOffset returns the byte offset for the given LSP protocol position. +func (f *File) GetOffset(p types.Position) (int, error) { + if p.Line < 0 || p.Line >= len(f.lineOffsets) { + return 0, fmt.Errorf("position %s is out of range", p) + } + + if p.Line == len(f.lineOffsets) { + if p.Character == 0 { + return len(f.bytes), nil + } + + return 0, fmt.Errorf("position %s is out of range", p) + } + + rest := f.bytes[f.lineOffsets[p.Line]:] + + for i := 0; i < p.Character; i++ { + r, size := utf8.DecodeRune(rest) + + if size == 0 || r == '\n' { + return 0, fmt.Errorf("position %s is out of range", p) + } + + if r == utf8.RuneError { + return 0, errors.New("invalid UTF-8 encoding") + } + + if r >= 0x10000 { + // UTF-16 surrogate pair + i++ + + if i == p.Character { + return 0, fmt.Errorf("position %s does not point to a valid UTF-16 code unit", p) + } + } + + rest = rest[size:] + } + + return len(f.bytes) - len(rest), nil +} diff --git a/internal/lsp/file_test.go b/internal/lsp/file_test.go new file mode 100644 index 0000000..15753d3 --- /dev/null +++ b/internal/lsp/file_test.go @@ -0,0 +1,167 @@ +package lsp_test + +import ( + "regexp" + "runtime" + "strconv" + "testing" + + . "github.com/armsnyder/openapiv3-lsp/internal/lsp" + "github.com/armsnyder/openapiv3-lsp/internal/lsp/types" +) + +func TestFile(t *testing.T) { + tests := []struct { + name string + text string + steps []Step + }{ + { + name: "hand type from empty", + text: "\n", + steps: []Step{ + {Text: "a", Range: "0:0-0:0", Want: "a\n"}, + {Text: "b", Range: "0:1-0:1", Want: "ab\n"}, + {Text: "\n", Range: "0:2-0:2", Want: "ab\n\n"}, + {Text: "c", Range: "1:0-1:0", Want: "ab\nc\n"}, + {Text: "d", Range: "1:1-1:1", Want: "ab\ncd\n"}, + }, + }, + { + name: "hand delete from end", + text: "ab\ncd\n", + steps: []Step{ + {Text: "", Range: "1:1-1:2", Want: "ab\nc\n"}, + {Text: "", Range: "1:0-1:1", Want: "ab\n\n"}, + {Text: "", Range: "0:2-0:2", Want: "ab\n\n"}, + {Text: "", Range: "1:0-2:0", Want: "ab\n"}, + {Text: "", Range: "0:1-0:2", Want: "a\n"}, + {Text: "", Range: "0:0-0:1", Want: "\n"}, + }, + }, + { + name: "add 2 lines to the middle then update each line", + text: "ab\ncd\n", + steps: []Step{ + {Text: "\n12\n34", Range: "0:2-0:2", Want: "ab\n12\n34\ncd\n"}, + {Text: "x", Range: "3:1-3:2", Want: "ab\n12\n34\ncx\n"}, + {Text: "y", Range: "2:1-2:2", Want: "ab\n12\n3y\ncx\n"}, + {Text: "z", Range: "1:1-1:2", Want: "ab\n1z\n3y\ncx\n"}, + }, + }, + { + name: "insert text at the beginning of the file", + text: "line1\nline2\nline3\n", + steps: []Step{ + {Text: "start\n", Range: "0:0-0:0", Want: "start\nline1\nline2\nline3\n"}, + }, + }, + { + name: "insert text at the end of the file", + text: "line1\nline2\nline3\n", + steps: []Step{ + {Text: "end\n", Range: "3:0-3:0", Want: "line1\nline2\nline3\nend\n"}, + }, + }, + { + name: "insert newline at the beginning and end of the file", + text: "line1\nline2\nline3\n", + steps: []Step{ + {Text: "\n", Range: "0:0-0:0", Want: "\nline1\nline2\nline3\n"}, + {Text: "\n", Range: "4:0-4:0", Want: "\nline1\nline2\nline3\n\n"}, + }, + }, + { + name: "delete text spanning multiple lines", + text: "line1\nline2\nline3\nline4\n", + steps: []Step{ + {Text: "", Range: "1:2-3:4", Want: "line1\nli4\n"}, + {Text: "x", Range: "1:3-1:3", Want: "line1\nli4x\n"}, + }, + }, + { + name: "replace text spanning multiple lines with text containing newlines", + text: "line1\nline2\nline3\nline4\n", + steps: []Step{ + {Text: "new\ntext\n", Range: "1:2-3:4", Want: "line1\nlinew\ntext\n4\n"}, + {Text: "x", Range: "3:1-3:1", Want: "line1\nlinew\ntext\n4x\n"}, + }, + }, + { + name: "delete final line", + text: "a\n", + steps: []Step{ + {Text: "", Range: "0:0-1:0", Want: ""}, + }, + }, + { + name: "add to empty file without newline", + text: "", + steps: []Step{ + {Text: "\n\n", Range: "0:0-0:0", Want: "\n\n"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var f File + f.Reset([]byte(tt.text)) + for i, step := range tt.steps { + func() { + defer func() { + if r := recover(); r != nil { + stack := make([]byte, 1<<16) + stack = stack[:runtime.Stack(stack, false)] + t.Fatalf("step %d: %v\n%s", i, r, stack) + } + }() + event := newEvent(step.Text, step.Range) + if err := f.ApplyChange(event); err != nil { + t.Fatalf("step %d: %v", i, err) + } + if got := string(f.Bytes()); got != step.Want { + t.Fatalf("step %d: got %q, want %q", i, got, step.Want) + } + }() + } + }) + } +} + +type Step struct { + Text string + Range string + Want string +} + +var rangePattern = regexp.MustCompile(`^(\d+):(\d+)-(\d+):(\d+)$`) + +func newEvent(text, rng string) types.TextDocumentContentChangeEvent { + match := rangePattern.FindSubmatch([]byte(rng)) + if match == nil { + panic("invalid range") + } + + return types.TextDocumentContentChangeEvent{ + Text: text, + Range: &types.Range{ + Start: types.Position{ + Line: mustAtoi(match[1]), + Character: mustAtoi(match[2]), + }, + End: types.Position{ + Line: mustAtoi(match[3]), + Character: mustAtoi(match[4]), + }, + }, + } +} + +func mustAtoi(b []byte) int { + i, err := strconv.Atoi(string(b)) + if err != nil { + panic(err) + } + return i +} diff --git a/internal/lsp/handler.go b/internal/lsp/handler.go new file mode 100644 index 0000000..a193bef --- /dev/null +++ b/internal/lsp/handler.go @@ -0,0 +1,49 @@ +package lsp + +import "github.com/armsnyder/openapiv3-lsp/internal/lsp/types" + +// Handler is an interface for handling LSP requests. +type Handler interface { + Capabilities() types.ServerCapabilities + HandleOpen(params types.DidOpenTextDocumentParams) error + HandleClose(params types.DidCloseTextDocumentParams) error + HandleChange(params types.DidChangeTextDocumentParams) error + HandleDefinition(params types.DefinitionParams) ([]types.Location, error) + HandleReferences(params types.ReferenceParams) ([]types.Location, error) +} + +// NopHandler can be embedded in a struct to provide no-op implementations of +// ununsed Handler methods. +type NopHandler struct{} + +// Capabilities implements Handler. +func (NopHandler) Capabilities() types.ServerCapabilities { + return types.ServerCapabilities{} +} + +// HandleOpen implements Handler. +func (NopHandler) HandleOpen(types.DidOpenTextDocumentParams) error { + return nil +} + +// HandleClose implements Handler. +func (NopHandler) HandleClose(types.DidCloseTextDocumentParams) error { + return nil +} + +// HandleChange implements Handler. +func (NopHandler) HandleChange(types.DidChangeTextDocumentParams) error { + return nil +} + +// HandleDefinition implements Handler. +func (NopHandler) HandleDefinition(types.DefinitionParams) ([]types.Location, error) { + return []types.Location{}, nil +} + +// HandleReferences implements Handler. +func (NopHandler) HandleReferences(types.ReferenceParams) ([]types.Location, error) { + return []types.Location{}, nil +} + +var _ Handler = NopHandler{} diff --git a/internal/lsp/jsonrpc/doc.go b/internal/lsp/jsonrpc/doc.go new file mode 100644 index 0000000..893fd74 --- /dev/null +++ b/internal/lsp/jsonrpc/doc.go @@ -0,0 +1,2 @@ +// Package jsonrpc provides utilities for working with the JSON-RPC protocol. +package jsonrpc diff --git a/internal/lsp/jsonrpc/jsonrpc.go b/internal/lsp/jsonrpc/jsonrpc.go new file mode 100644 index 0000000..c72368a --- /dev/null +++ b/internal/lsp/jsonrpc/jsonrpc.go @@ -0,0 +1,60 @@ +package jsonrpc + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strconv" +) + +// Split is a bufio.SplitFunc that splits JSON-RPC messages. +func Split(data []byte, _ bool) (advance int, token []byte, err error) { + const headerDelimiter = "\r\n\r\n" + const contentLengthPrefix = "Content-Length: " + + header, payload, found := bytes.Cut(data, []byte(headerDelimiter)) + if !found { + return 0, nil, nil + } + + contentLengthIndex := bytes.Index(header, []byte(contentLengthPrefix)) + if contentLengthIndex == -1 { + return 0, nil, errors.New("missing content length: header not found") + } + + contentLengthValueStart := contentLengthIndex + len(contentLengthPrefix) + contentLengthValueLength := bytes.IndexByte(header[contentLengthValueStart:], '\r') + if contentLengthValueLength == -1 { + contentLengthValueLength = len(header) - contentLengthValueStart + } + + contentLength, err := strconv.Atoi(string(header[contentLengthValueStart : contentLengthValueStart+contentLengthValueLength])) + if err != nil { + return 0, nil, errors.New("invalid content length") + } + + if len(payload) < contentLength { + return 0, nil, nil + } + + return len(header) + len(headerDelimiter) + contentLength, payload[:contentLength], nil +} + +// Write writes the given JSON-encodable message using the JSON-RPC protocol. +func Write(w io.Writer, message any) error { + payload, err := json.Marshal(message) + if err != nil { + return err + } + + return WritePayload(w, payload) +} + +// WritePayload writes the given JSON payload using the JSON-RPC protocol. +func WritePayload(w io.Writer, payload []byte) error { + packet := append([]byte("Content-Length: "+strconv.Itoa(len(payload))+"\r\n\r\n"), payload...) + + _, err := w.Write(packet) + return err +} diff --git a/internal/lsp/jsonrpc/jsonrpc_test.go b/internal/lsp/jsonrpc/jsonrpc_test.go new file mode 100644 index 0000000..64c7528 --- /dev/null +++ b/internal/lsp/jsonrpc/jsonrpc_test.go @@ -0,0 +1,148 @@ +package jsonrpc_test + +import ( + "bufio" + "bytes" + "fmt" + "io" + "strings" + "testing" + "testing/iotest" + + . "github.com/armsnyder/openapiv3-lsp/internal/lsp/jsonrpc" +) + +func TestSplit(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + { + name: "empty", + input: "", + want: nil, + }, + { + name: "single message", + input: "Content-Length: 17\r\n\r\n{\"jsonrpc\":\"2.0\"}", + want: []string{ + `{"jsonrpc":"2.0"}`, + }, + }, + { + name: "multiple messages", + input: "Content-Length: 17\r\n\r\n{\"jsonrpc\":\"2.0\"}\nContent-Length: 24\r\n\r\n{\"jsonrpc\":\"2.0\",\"id\":1}", + want: []string{ + `{"jsonrpc":"2.0"}`, + `{"jsonrpc":"2.0","id":1}`, + }, + }, + { + name: "extra headers before", + input: "Extra-Header: foo\r\nContent-Length: 17\r\n\r\n{\"jsonrpc\":\"2.0\"}", + want: []string{ + `{"jsonrpc":"2.0"}`, + }, + }, + { + name: "extra headers after", + input: "Content-Length: 17\r\nExtra-Header: foo\r\n\r\n{\"jsonrpc\":\"2.0\"}", + want: []string{ + `{"jsonrpc":"2.0"}`, + }, + }, + { + name: "incomplete stream", + input: "Content-Length: 17\r\n\r\n{\"jsonrp", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + readers := []io.Reader{ + strings.NewReader(tt.input), + iotest.OneByteReader(strings.NewReader(tt.input)), + iotest.HalfReader(strings.NewReader(tt.input)), + } + + for i, r := range readers { + t.Run(fmt.Sprintf("reader #%d", i), func(t *testing.T) { + scanner := bufio.NewScanner(r) + scanner.Split(Split) + + var got []string + for scanner.Scan() { + got = append(got, scanner.Text()) + } + + if err := scanner.Err(); err != nil { + t.Fatal("error while scanning input: ", err) + } + + if len(tt.want) == 0 && len(got) == 0 { + return + } + + for i := 0; i < len(tt.want) && i < len(got); i++ { + if got[i] != tt.want[i] { + t.Errorf("message #%d: got %q, want %q", i, got[i], tt.want[i]) + } + } + + if len(got) != len(tt.want) { + t.Errorf("got %d messages, want %d", len(got), len(tt.want)) + } + }) + } + }) + } +} + +func TestWrite(t *testing.T) { + tests := []struct { + name string + input any + want string + }{ + { + name: "null", + input: nil, + want: "Content-Length: 4\r\n\r\nnull", + }, + { + name: "empty string", + input: "", + want: "Content-Length: 2\r\n\r\n\"\"", + }, + { + name: "string", + input: "foo", + want: "Content-Length: 5\r\n\r\n\"foo\"", + }, + { + name: "number", + input: 42, + want: "Content-Length: 2\r\n\r\n42", + }, + { + name: "object", + input: map[string]any{"foo": "bar"}, + want: "Content-Length: 13\r\n\r\n{\"foo\":\"bar\"}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + buf := &bytes.Buffer{} + if err := Write(buf, tt.input); err != nil { + t.Fatal(err) + } + + if got := buf.String(); got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go new file mode 100644 index 0000000..368d0f7 --- /dev/null +++ b/internal/lsp/server.go @@ -0,0 +1,169 @@ +package lsp + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "log" + + "github.com/armsnyder/openapiv3-lsp/internal/lsp/jsonrpc" + "github.com/armsnyder/openapiv3-lsp/internal/lsp/types" +) + +// Server is an LSP server. It handles the I/O and delegates handling of +// requests to a Handler. +type Server struct { + Reader io.Reader + Writer io.Writer + Handler Handler + ServerInfo types.ServerInfo +} + +// Run is a blocking function that reads from the server's Reader, processes +// requests, and writes responses to the server's Writer. It returns an error +// if the server stops unexpectedly. +func (s *Server) Run() error { + scanner := bufio.NewScanner(s.Reader) + scanner.Split(jsonrpc.Split) + + log.Println("LSP server started") + + for scanner.Scan() { + if err := s.handleRequestPayload(scanner.Bytes()); err != nil { + if errors.Is(err, errShutdown) { + log.Println("LSP server shutting down") + return nil + } + + return err + } + } + + return scanner.Err() +} + +func (s *Server) handleRequestPayload(payload []byte) (err error) { + var request types.RequestMessage + + err = json.Unmarshal(payload, &request) + if err != nil { + return err + } + + if request.JSONRPC != "2.0" { + return errors.New("unknown jsonrpc version") + } + + if request.Method == "" { + return errors.New("request is missing a method") + } + + return s.handleRequest(request) +} + +var errShutdown = errors.New("shutdown") + +func (s *Server) handleRequest(request types.RequestMessage) error { + switch request.Method { + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize + case "initialize": + var params types.InitializeParams + if err := json.Unmarshal(request.Params, ¶ms); err != nil { + return fmt.Errorf("invalid initialize params: %w", err) + } + + log.Printf("Connected to: %s %s", params.ClientInfo.Name, params.ClientInfo.Version) + + s.write(request, types.InitializeResult{ + Capabilities: s.Handler.Capabilities(), + ServerInfo: s.ServerInfo, + }) + + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialized + case "initialized": + // No-op + + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#shutdown + case "shutdown": + s.write(request, nil) + return errShutdown + + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_didOpen + case "textDocument/didOpen": + var params types.DidOpenTextDocumentParams + if err := json.Unmarshal(request.Params, ¶ms); err != nil { + return fmt.Errorf("invalid textDocument/didOpen params: %w", err) + } + + if err := s.Handler.HandleOpen(params); err != nil { + return err + } + + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_didClose + case "textDocument/didClose": + var params types.DidCloseTextDocumentParams + if err := json.Unmarshal(request.Params, ¶ms); err != nil { + return fmt.Errorf("invalid textDocument/didClose params: %w", err) + } + + if err := s.Handler.HandleClose(params); err != nil { + return err + } + + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_didChange + case "textDocument/didChange": + var params types.DidChangeTextDocumentParams + if err := json.Unmarshal(request.Params, ¶ms); err != nil { + return fmt.Errorf("invalid textDocument/didChange params: %w", err) + } + + if err := s.Handler.HandleChange(params); err != nil { + return err + } + + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_definition + case "textDocument/definition": + var params types.DefinitionParams + if err := json.Unmarshal(request.Params, ¶ms); err != nil { + return fmt.Errorf("invalid textDocument/definition params: %w", err) + } + + location, err := s.Handler.HandleDefinition(params) + if err != nil { + return err + } + + s.write(request, location) + + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocument_references + case "textDocument/references": + var params types.ReferenceParams + if err := json.Unmarshal(request.Params, ¶ms); err != nil { + return fmt.Errorf("invalid textDocument/references params: %w", err) + } + + locations, err := s.Handler.HandleReferences(params) + if err != nil { + return err + } + + s.write(request, locations) + + default: + log.Printf("Warning: Request with unknown method %q", request.Method) + } + + return nil +} + +func (s *Server) write(request types.RequestMessage, result any) { + if err := jsonrpc.Write(s.Writer, types.ResponseMessage{ + JSONRPC: "2.0", + ID: request.ID, + Result: result, + }); err != nil { + log.Printf("Error writing response: %v", err) + } +} diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go new file mode 100644 index 0000000..81ab16b --- /dev/null +++ b/internal/lsp/server_test.go @@ -0,0 +1,238 @@ +package lsp_test + +import ( + "bufio" + "bytes" + "fmt" + "io" + "testing" + + "go.uber.org/mock/gomock" + + . "github.com/armsnyder/openapiv3-lsp/internal/lsp" + "github.com/armsnyder/openapiv3-lsp/internal/lsp/jsonrpc" + "github.com/armsnyder/openapiv3-lsp/internal/lsp/testutil" + "github.com/armsnyder/openapiv3-lsp/internal/lsp/types" +) + +func TestServer_Basic(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, s *Server, h *testutil.MockHandler) + requests []string + wantResponses []string + }{ + { + name: "initialize with default capabilities", + setup: func(t *testing.T, s *Server, h *testutil.MockHandler) { + h.EXPECT().Capabilities().Return(types.ServerCapabilities{}) + }, + requests: []string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`, + }, + wantResponses: []string{ + `{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"textDocumentSync":{"change":0}},"serverInfo":{"name":"test-lsp","version":"0.1.0"}}}`, + }, + }, + { + name: "initialize with all capabilities", + setup: func(t *testing.T, s *Server, h *testutil.MockHandler) { + h.EXPECT().Capabilities().Return(types.ServerCapabilities{ + TextDocumentSync: types.TextDocumentSyncOptions{ + OpenClose: true, + Change: types.SyncIncremental, + }, + DefinitionProvider: true, + ReferencesProvider: true, + }) + }, + requests: []string{ + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`, + }, + wantResponses: []string{ + `{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"textDocumentSync":{"openClose":true,"change":2},"definitionProvider":true,"referencesProvider":true},"serverInfo":{"name":"test-lsp","version":"0.1.0"}}}`, + }, + }, + { + name: "initialized", + requests: []string{ + `{"jsonrpc":"2.0","id":1,"method":"initialized","params":{}}`, + }, + }, + { + name: "shutdown", + requests: []string{ + `{"jsonrpc":"2.0","id":1,"method":"shutdown","params":{}}`, + }, + wantResponses: []string{ + `{"jsonrpc":"2.0","id":1,"result":null}`, + }, + }, + { + name: "textDocument/didOpen", + setup: func(t *testing.T, s *Server, h *testutil.MockHandler) { + h.EXPECT().HandleOpen(types.DidOpenTextDocumentParams{ + TextDocument: types.TextDocumentItem{ + URI: "file:///foo.txt", + Text: "hello world", + }, + }).Return(nil) + }, + requests: []string{ + `{"jsonrpc":"2.0","id":1,"method":"textDocument/didOpen","params":{"textDocument":{"uri":"file:///foo.txt","text":"hello world"}}}`, + }, + }, + { + name: "textDocument/didClose", + setup: func(t *testing.T, s *Server, h *testutil.MockHandler) { + h.EXPECT().HandleClose(types.DidCloseTextDocumentParams{ + TextDocument: types.TextDocumentIdentifier{URI: "file:///foo.txt"}, + }).Return(nil) + }, + requests: []string{ + `{"jsonrpc":"2.0","id":1,"method":"textDocument/didClose","params":{"textDocument":{"uri":"file:///foo.txt"}}}`, + }, + }, + { + name: "textDocument/didChange full sync", + setup: func(t *testing.T, s *Server, h *testutil.MockHandler) { + h.EXPECT().HandleChange(types.DidChangeTextDocumentParams{ + TextDocument: types.TextDocumentIdentifier{URI: "file:///foo.txt"}, + ContentChanges: []types.TextDocumentContentChangeEvent{ + {Text: "hello world"}, + }, + }).Return(nil) + }, + requests: []string{ + `{"jsonrpc":"2.0","id":1,"method":"textDocument/didChange","params":{"textDocument":{"uri":"file:///foo.txt","version":42},"contentChanges":[{"text":"hello world"}]}}`, + }, + }, + { + name: "textDocument/didChange incremental sync", + setup: func(t *testing.T, s *Server, h *testutil.MockHandler) { + h.EXPECT().HandleChange(types.DidChangeTextDocumentParams{ + TextDocument: types.TextDocumentIdentifier{URI: "file:///foo.txt"}, + ContentChanges: []types.TextDocumentContentChangeEvent{{ + Text: "carl", + Range: &types.Range{Start: types.Position{Line: 0, Character: 6}, End: types.Position{Line: 0, Character: 10}}, + }}, + }).Return(nil) + }, + requests: []string{ + `{"jsonrpc":"2.0","id":1,"method":"textDocument/didChange","params":{"textDocument":{"uri":"file:///foo.txt","version":42},"contentChanges":[{"text":"carl","range":{"start":{"line":0,"character":6},"end":{"line":0,"character":10}}}]}}`, + }, + }, + { + name: "textDocument/definition", + setup: func(t *testing.T, s *Server, h *testutil.MockHandler) { + h.EXPECT().HandleDefinition(types.DefinitionParams{ + TextDocumentPositionParams: types.TextDocumentPositionParams{ + TextDocument: types.TextDocumentIdentifier{URI: "file:///foo.txt"}, + Position: types.Position{Line: 1, Character: 2}, + }, + }).Return([]types.Location{{ + URI: "file:///bar.txt", + Range: types.Range{Start: types.Position{Line: 3, Character: 4}, End: types.Position{Line: 5, Character: 6}}, + }}, nil) + }, + requests: []string{ + `{"jsonrpc":"2.0","id":1,"method":"textDocument/definition","params":{"textDocument":{"uri":"file:///foo.txt"},"position":{"line":1,"character":2}}}`, + }, + wantResponses: []string{ + `{"jsonrpc":"2.0","id":1,"result":[{"uri":"file:///bar.txt","range":{"start":{"line":3,"character":4},"end":{"line":5,"character":6}}}]}`, + }, + }, + { + name: "textDocument/references", + setup: func(t *testing.T, s *Server, h *testutil.MockHandler) { + h.EXPECT().HandleReferences(types.ReferenceParams{ + TextDocumentPositionParams: types.TextDocumentPositionParams{ + TextDocument: types.TextDocumentIdentifier{URI: "file:///foo.txt"}, + Position: types.Position{Line: 1, Character: 2}, + }, + }).Return([]types.Location{ + { + URI: "file:///bar.txt", + Range: types.Range{Start: types.Position{Line: 3, Character: 4}, End: types.Position{Line: 5, Character: 6}}, + }, + }, nil) + }, + requests: []string{ + `{"jsonrpc":"2.0","id":1,"method":"textDocument/references","params":{"textDocument":{"uri":"file:///foo.txt"},"position":{"line":1,"character":2}}}`, + }, + wantResponses: []string{ + `{"jsonrpc":"2.0","id":1,"result":[{"uri":"file:///bar.txt","range":{"start":{"line":3,"character":4},"end":{"line":5,"character":6}}}]}`, + }, + }, + { + name: "unknown method", + requests: []string{ + `{"jsonrpc":"2.0","id":1,"method":"foo","params":{}}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + handler := testutil.NewMockHandler(ctrl) + reader := &bytes.Buffer{} + writer := &bytes.Buffer{} + server := Server{ + ServerInfo: types.ServerInfo{ + Name: "test-lsp", + Version: "0.1.0", + }, + Handler: handler, + Reader: reader, + Writer: writer, + } + + if tt.setup != nil { + tt.setup(t, &server, handler) + } + + send := RPCWriter{Writer: reader} + for _, req := range tt.requests { + fmt.Fprint(send, req) + } + + if err := server.Run(); err != nil { + t.Fatal("server.Run() error: ", err) + } + + scanner := bufio.NewScanner(writer) + scanner.Split(jsonrpc.Split) + + for _, want := range tt.wantResponses { + if !scanner.Scan() { + t.Fatal("missing response: ", want) + } + + if got := scanner.Text(); got != want { + t.Errorf("got response:\n%s\n\nexpected response:\n%s", got, want) + } + } + + if err := scanner.Err(); err != nil { + t.Fatal("error while reading server responses: ", err) + } + }) + } +} + +type RPCWriter struct { + Writer io.Writer +} + +func (w RPCWriter) Write(p []byte) (n int, err error) { + if err := jsonrpc.WritePayload(w.Writer, p); err != nil { + return 0, err + } + + return len(p), nil +} + +var _ io.Writer = RPCWriter{} diff --git a/internal/lsp/testutil/handler.go b/internal/lsp/testutil/handler.go new file mode 100644 index 0000000..fb2350d --- /dev/null +++ b/internal/lsp/testutil/handler.go @@ -0,0 +1,126 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: internal/lsp/handler.go +// +// Generated by this command: +// +// mockgen -source internal/lsp/handler.go -destination internal/lsp/testutil/handler.go -package testutil +// + +// Package testutil is a generated GoMock package. +package testutil + +import ( + reflect "reflect" + + types "github.com/armsnyder/openapiv3-lsp/internal/lsp/types" + gomock "go.uber.org/mock/gomock" +) + +// MockHandler is a mock of Handler interface. +type MockHandler struct { + ctrl *gomock.Controller + recorder *MockHandlerMockRecorder +} + +// MockHandlerMockRecorder is the mock recorder for MockHandler. +type MockHandlerMockRecorder struct { + mock *MockHandler +} + +// NewMockHandler creates a new mock instance. +func NewMockHandler(ctrl *gomock.Controller) *MockHandler { + mock := &MockHandler{ctrl: ctrl} + mock.recorder = &MockHandlerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockHandler) EXPECT() *MockHandlerMockRecorder { + return m.recorder +} + +// Capabilities mocks base method. +func (m *MockHandler) Capabilities() types.ServerCapabilities { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Capabilities") + ret0, _ := ret[0].(types.ServerCapabilities) + return ret0 +} + +// Capabilities indicates an expected call of Capabilities. +func (mr *MockHandlerMockRecorder) Capabilities() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Capabilities", reflect.TypeOf((*MockHandler)(nil).Capabilities)) +} + +// HandleChange mocks base method. +func (m *MockHandler) HandleChange(params types.DidChangeTextDocumentParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HandleChange", params) + ret0, _ := ret[0].(error) + return ret0 +} + +// HandleChange indicates an expected call of HandleChange. +func (mr *MockHandlerMockRecorder) HandleChange(params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleChange", reflect.TypeOf((*MockHandler)(nil).HandleChange), params) +} + +// HandleClose mocks base method. +func (m *MockHandler) HandleClose(params types.DidCloseTextDocumentParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HandleClose", params) + ret0, _ := ret[0].(error) + return ret0 +} + +// HandleClose indicates an expected call of HandleClose. +func (mr *MockHandlerMockRecorder) HandleClose(params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleClose", reflect.TypeOf((*MockHandler)(nil).HandleClose), params) +} + +// HandleDefinition mocks base method. +func (m *MockHandler) HandleDefinition(params types.DefinitionParams) ([]types.Location, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HandleDefinition", params) + ret0, _ := ret[0].([]types.Location) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HandleDefinition indicates an expected call of HandleDefinition. +func (mr *MockHandlerMockRecorder) HandleDefinition(params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleDefinition", reflect.TypeOf((*MockHandler)(nil).HandleDefinition), params) +} + +// HandleOpen mocks base method. +func (m *MockHandler) HandleOpen(params types.DidOpenTextDocumentParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HandleOpen", params) + ret0, _ := ret[0].(error) + return ret0 +} + +// HandleOpen indicates an expected call of HandleOpen. +func (mr *MockHandlerMockRecorder) HandleOpen(params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleOpen", reflect.TypeOf((*MockHandler)(nil).HandleOpen), params) +} + +// HandleReferences mocks base method. +func (m *MockHandler) HandleReferences(params types.ReferenceParams) ([]types.Location, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "HandleReferences", params) + ret0, _ := ret[0].([]types.Location) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// HandleReferences indicates an expected call of HandleReferences. +func (mr *MockHandlerMockRecorder) HandleReferences(params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandleReferences", reflect.TypeOf((*MockHandler)(nil).HandleReferences), params) +} diff --git a/internal/lsp/types/base_protocol.go b/internal/lsp/types/base_protocol.go new file mode 100644 index 0000000..bcda32c --- /dev/null +++ b/internal/lsp/types/base_protocol.go @@ -0,0 +1,59 @@ +package types + +import ( + "encoding/json" + "strconv" +) + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#requestMessage. +type RequestMessage struct { + JSONRPC string `json:"jsonrpc"` + ID *RequestID `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#requestMessage. +type RequestID struct { + IntVal int + StringVal string +} + +func (r *RequestID) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + + if data[0] == '"' { + return json.Unmarshal(data, &r.StringVal) + } + + return json.Unmarshal(data, &r.IntVal) +} + +func (r RequestID) MarshalJSON() ([]byte, error) { + if r.StringVal != "" { + return json.Marshal(r.StringVal) + } + + return json.Marshal(r.IntVal) +} + +func (r *RequestID) String() string { + if r == nil { + return "" + } + + if r.StringVal != "" { + return r.StringVal + } + + return strconv.Itoa(r.IntVal) +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#responseMessage. +type ResponseMessage struct { + JSONRPC string `json:"jsonrpc"` + ID *RequestID `json:"id"` + Result any `json:"result"` +} diff --git a/internal/lsp/types/base_protocol_test.go b/internal/lsp/types/base_protocol_test.go new file mode 100644 index 0000000..d1ea41c --- /dev/null +++ b/internal/lsp/types/base_protocol_test.go @@ -0,0 +1,34 @@ +package types_test + +import ( + "encoding/json" + "testing" + + . "github.com/armsnyder/openapiv3-lsp/internal/lsp/types" +) + +func TestResponseMessage_MarshalJSON(t *testing.T) { + tests := []struct { + name string + message ResponseMessage + want string + }{ + { + name: "empty", + message: ResponseMessage{JSONRPC: "2.0"}, + want: `{"jsonrpc":"2.0","id":null,"result":null}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s, err := json.Marshal(tt.message) + if err != nil { + t.Fatal(err) + } + if string(s) != tt.want { + t.Errorf("got %s, want %s", s, tt.want) + } + }) + } +} diff --git a/internal/lsp/types/basic_json_structures.go b/internal/lsp/types/basic_json_structures.go new file mode 100644 index 0000000..87c55e1 --- /dev/null +++ b/internal/lsp/types/basic_json_structures.go @@ -0,0 +1,46 @@ +package types + +import "strconv" + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position. +type Position struct { + Line int `json:"line"` + Character int `json:"character"` +} + +func (p Position) String() string { + return strconv.Itoa(p.Line) + ":" + strconv.Itoa(p.Character) +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#range. +type Range struct { + Start Position `json:"start"` + End Position `json:"end"` +} + +func (r Range) String() string { + return r.Start.String() + "-" + r.End.String() +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentItem. +type TextDocumentItem struct { + URI string `json:"uri"` + Text string `json:"text"` +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentIdentifier. +type TextDocumentIdentifier struct { + URI string `json:"uri"` +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentPositionParams. +type TextDocumentPositionParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` + Position Position `json:"position"` +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#location. +type Location struct { + URI string `json:"uri"` + Range Range `json:"range"` +} diff --git a/internal/lsp/types/document_synchronization.go b/internal/lsp/types/document_synchronization.go new file mode 100644 index 0000000..0c74f85 --- /dev/null +++ b/internal/lsp/types/document_synchronization.go @@ -0,0 +1,38 @@ +package types + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentSyncKind. +type TextDocumentSyncKind int + +const ( + SyncNone TextDocumentSyncKind = 0 + SyncFull TextDocumentSyncKind = 1 + SyncIncremental TextDocumentSyncKind = 2 +) + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentSyncOptions. +type TextDocumentSyncOptions struct { + OpenClose bool `json:"openClose,omitempty"` + Change TextDocumentSyncKind `json:"change"` +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#didOpenTextDocumentParams. +type DidOpenTextDocumentParams struct { + TextDocument TextDocumentItem `json:"textDocument"` +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#didChangeTextDocumentParams. +type DidChangeTextDocumentParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` + ContentChanges []TextDocumentContentChangeEvent `json:"contentChanges"` +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentContentChangeEvent. +type TextDocumentContentChangeEvent struct { + Text string `json:"text"` + Range *Range `json:"range,omitempty"` +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#didCloseTextDocumentParams. +type DidCloseTextDocumentParams struct { + TextDocument TextDocumentIdentifier `json:"textDocument"` +} diff --git a/internal/lsp/types/language_features.go b/internal/lsp/types/language_features.go new file mode 100644 index 0000000..7bf0a83 --- /dev/null +++ b/internal/lsp/types/language_features.go @@ -0,0 +1,11 @@ +package types + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#definitionParams. +type DefinitionParams struct { + TextDocumentPositionParams +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#referenceParams. +type ReferenceParams struct { + TextDocumentPositionParams +} diff --git a/internal/lsp/types/lifecycle_messages.go b/internal/lsp/types/lifecycle_messages.go new file mode 100644 index 0000000..e96bb8f --- /dev/null +++ b/internal/lsp/types/lifecycle_messages.go @@ -0,0 +1,28 @@ +package types + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initializeParams. +type InitializeParams struct { + ClientInfo struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"clientInfo"` +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initializeResult. +type InitializeResult struct { + Capabilities ServerCapabilities `json:"capabilities"` + ServerInfo ServerInfo `json:"serverInfo"` +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#serverCapabilities. +type ServerCapabilities struct { + TextDocumentSync TextDocumentSyncOptions `json:"textDocumentSync"` + DefinitionProvider bool `json:"definitionProvider,omitempty"` + ReferencesProvider bool `json:"referencesProvider,omitempty"` +} + +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initializeResult. +type ServerInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} diff --git a/internal/lsp/utf16.go b/internal/lsp/utf16.go new file mode 100644 index 0000000..ac22887 --- /dev/null +++ b/internal/lsp/utf16.go @@ -0,0 +1,30 @@ +package lsp + +import "unicode/utf8" + +// UTF16Len returns the number of UTF-16 code units required to encode the +// given UTF-8 byte slice. +func UTF16Len(s []byte) int { + n := 0 + + for len(s) > 0 { + n++ + + if s[0] < 0x80 { + // ASCII optimization + s = s[1:] + continue + } + + r, size := utf8.DecodeRune(s) + + if r >= 0x10000 { + // UTF-16 surrogate pair + n++ + } + + s = s[size:] + } + + return n +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..fc732b5 --- /dev/null +++ b/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "log" + "os" + + "github.com/armsnyder/openapiv3-lsp/internal/analysis" + "github.com/armsnyder/openapiv3-lsp/internal/lsp" + "github.com/armsnyder/openapiv3-lsp/internal/lsp/types" +) + +func main() { + log.SetFlags(0) + + server := &lsp.Server{ + ServerInfo: types.ServerInfo{ + Name: "openapiv3-lsp", + Version: "0.1.0", + }, + Reader: os.Stdin, + Writer: os.Stdout, + Handler: &analysis.Handler{}, + } + + if err := server.Run(); err != nil { + log.Fatal("LSP server error: ", err) + } +}