diff --git a/languageserver/server/completion_test.go b/languageserver/server/completion_test.go new file mode 100644 index 00000000..34d413d7 --- /dev/null +++ b/languageserver/server/completion_test.go @@ -0,0 +1,168 @@ +/* + * Cadence languageserver - The Cadence language server + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package server + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/onflow/cadence-tools/languageserver/protocol" +) + +func TestCompletionDocString(t *testing.T) { + t.Parallel() + + t.Run("member completion", func(t *testing.T) { + server, err := NewServer() + require.NoError(t, err) + + // Struct with a documented method; trigger member completion on an instance. + // Use "f.b" so the parser creates a member access expression. + const code = ` + access(all) struct Foo { + /// Does something. + /// + /// @param x: The input + /// @return The output + access(all) fun bar(x: Int): String { + return "" + } + } + + access(all) fun test() { + let f = Foo() + f.b + } + ` + // Line 13 (0-indexed): " f.b" + // 14 spaces + "f.b": dot is at column 15, "b" at column 16. + // Completion is requested after the dot = column 16. + + uri := protocol.DocumentURI("file:///completion_member.cdc") + server.documents[uri] = Document{Text: code, Version: 1} + _, err = server.getDiagnostics(uri, code, 1, func(*protocol.LogMessageParams) {}) + require.NoError(t, err) + + items, err := server.Completion( + nil, + &protocol.CompletionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri}, + Position: protocol.Position{Line: 13, Character: 16}, + }, + }, + ) + require.NoError(t, err) + + // Find the "bar" completion item + var barItem *protocol.CompletionItem + for _, item := range items { + if item.Label == "bar" { + barItem = item + break + } + } + require.NotNil(t, barItem, "expected 'bar' in completion items") + + resolved, err := server.ResolveCompletionItem(nil, barItem) + require.NoError(t, err) + require.NotNil(t, resolved.Documentation) + + assert.Equal( + t, + &protocol.Or_CompletionItem_documentation{ + Value: protocol.MarkupContent{ + Kind: "markdown", + Value: "Does something.\n\n" + + "**Parameters**\n\n" + + "- `x`: The input\n\n" + + "**Returns** The output", + }, + }, + resolved.Documentation, + ) + }) + + t.Run("range completion", func(t *testing.T) { + server, err := NewServer() + require.NoError(t, err) + + // A documented function; trigger range (non-member) completion inside another function body. + const code = ` + /// Adds two numbers. + /// + /// @param a: The first number + /// @param b: The second number + /// @return The sum + access(all) fun add(a: Int, b: Int): Int { + return a + b + } + + access(all) fun test() { + ad + } + ` + // "ad" is on line 12, columns 14-15. Request completion at column 16 (end of "ad"). + + uri := protocol.DocumentURI("file:///completion_range.cdc") + server.documents[uri] = Document{Text: code, Version: 1} + // There will be checker errors since "ad" is not a valid expression, + // but we only need the ranges to be populated. + _, _ = server.getDiagnostics(uri, code, 1, func(*protocol.LogMessageParams) {}) + + items, err := server.Completion( + nil, + &protocol.CompletionParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri}, + Position: protocol.Position{Line: 12, Character: 16}, + }, + }, + ) + require.NoError(t, err) + + // Find the "add" completion item + var addItem *protocol.CompletionItem + for _, item := range items { + if item.Label == "add" { + addItem = item + break + } + } + require.NotNil(t, addItem, "expected 'add' in completion items") + + resolved, err := server.ResolveCompletionItem(nil, addItem) + require.NoError(t, err) + require.NotNil(t, resolved.Documentation) + + assert.Equal( + t, + &protocol.Or_CompletionItem_documentation{ + Value: "Adds two numbers.\n\n" + + "**Parameters**\n\n" + + "- `a`: The first number\n" + + "- `b`: The second number\n\n" + + "**Returns** The sum", + }, + resolved.Documentation, + ) + }) +} diff --git a/languageserver/server/docstring.go b/languageserver/server/docstring.go new file mode 100644 index 00000000..78cdd159 --- /dev/null +++ b/languageserver/server/docstring.go @@ -0,0 +1,103 @@ +/* + * Cadence languageserver - The Cadence language server + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package server + +import ( + "fmt" + "strings" +) + +const ( + paramAnnotation = "@param " + returnAnnotation = "@return " +) + +func formatDocString(docString string) string { + docString = strings.TrimSpace(docString) + if docString == "" { + return "" + } + + var content strings.Builder + var params []string + var returnDoc string + var prevLineEmpty bool + var contentLines int + + for line := range strings.SplitSeq(docString, "\n") { + trimmed := strings.TrimSpace(line) + + if paramInfo, ok := strings.CutPrefix(trimmed, paramAnnotation); ok { + paramName, paramDesc, hasColon := strings.Cut(paramInfo, ":") + paramName = strings.TrimSpace(paramName) + if hasColon && len(paramName) > 0 { + paramDesc = strings.TrimSpace(paramDesc) + if len(paramDesc) > 0 { + params = append(params, fmt.Sprintf("- `%s`: %s", paramName, paramDesc)) + } else { + params = append(params, fmt.Sprintf("- `%s`", paramName)) + } + continue + } + // No valid colon/name — fall through to treat as normal content + } else if returnInfo, ok := strings.CutPrefix(trimmed, returnAnnotation); ok { + returnDoc = strings.TrimSpace(returnInfo) + continue + } + + isEmpty := len(trimmed) == 0 + if prevLineEmpty && isEmpty { + continue + } + + if contentLines > 0 { + content.WriteByte('\n') + } + content.WriteString(trimmed) + prevLineEmpty = isEmpty + contentLines++ + } + + // Trim trailing whitespace/newlines from content before appending sections + resultStr := strings.TrimRight(content.String(), "\n ") + + var finalBuilder strings.Builder + finalBuilder.WriteString(resultStr) + + if len(params) > 0 { + if finalBuilder.Len() > 0 { + finalBuilder.WriteString("\n\n") + } + finalBuilder.WriteString("**Parameters**\n") + for _, p := range params { + finalBuilder.WriteByte('\n') + finalBuilder.WriteString(p) + } + } + + if len(returnDoc) > 0 { + if finalBuilder.Len() > 0 { + finalBuilder.WriteString("\n\n") + } + finalBuilder.WriteString("**Returns** ") + finalBuilder.WriteString(returnDoc) + } + + return finalBuilder.String() +} diff --git a/languageserver/server/docstring_test.go b/languageserver/server/docstring_test.go new file mode 100644 index 00000000..cb5f995f --- /dev/null +++ b/languageserver/server/docstring_test.go @@ -0,0 +1,108 @@ +/* + * Cadence languageserver - The Cadence language server + * + * Copyright Flow Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package server + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatDocString(t *testing.T) { + t.Parallel() + + t.Run("empty", func(t *testing.T) { + assert.Equal(t, "", formatDocString("")) + assert.Equal(t, "", formatDocString(" ")) + }) + + t.Run("no annotations", func(t *testing.T) { + assert.Equal(t, + "Some description of a function.", + formatDocString("Some description of a function."), + ) + }) + + t.Run("only params", func(t *testing.T) { + input := `@param foo: Description of foo +@param bar: Description of bar` + expected := "**Parameters**\n\n- `foo`: Description of foo\n- `bar`: Description of bar" + assert.Equal(t, expected, formatDocString(input)) + }) + + t.Run("only return", func(t *testing.T) { + input := `@return Description of result` + expected := "**Returns** Description of result" + assert.Equal(t, expected, formatDocString(input)) + }) + + t.Run("content with params and return", func(t *testing.T) { + input := `Does something useful. + +@param foo: Description of foo +@param bar: Description of bar +@return The result` + expected := `Does something useful. + +**Parameters** + +- ` + "`foo`" + `: Description of foo +- ` + "`bar`" + `: Description of bar + +**Returns** The result` + assert.Equal(t, expected, formatDocString(input)) + }) + + t.Run("params interspersed in content", func(t *testing.T) { + input := `First line. +@param foo: Description of foo +Some middle text. +@param bar: Description of bar +@return The result` + expected := `First line. +Some middle text. + +**Parameters** + +- ` + "`foo`" + `: Description of foo +- ` + "`bar`" + `: Description of bar + +**Returns** The result` + assert.Equal(t, expected, formatDocString(input)) + }) + + t.Run("param without colon treated as content", func(t *testing.T) { + input := `@param noColon +@param valid: has description` + expected := "@param noColon\n\n**Parameters**\n\n- `valid`: has description" + assert.Equal(t, expected, formatDocString(input)) + }) + + t.Run("param without description", func(t *testing.T) { + input := `@param foo:` + expected := "**Parameters**\n\n- `foo`" + assert.Equal(t, expected, formatDocString(input)) + }) + + t.Run("consecutive blank lines collapsed", func(t *testing.T) { + input := "First line.\n\n\n\nSecond line." + expected := "First line.\n\nSecond line." + assert.Equal(t, expected, formatDocString(input)) + }) +} diff --git a/languageserver/server/hover_test.go b/languageserver/server/hover_test.go index 4515812e..0f201abd 100644 --- a/languageserver/server/hover_test.go +++ b/languageserver/server/hover_test.go @@ -30,42 +30,98 @@ import ( func TestHover(t *testing.T) { t.Parallel() - server, err := NewServer() - require.NoError(t, err) - - const code = ` - access(all) fun test() { - let foo = 1 - } - ` - - uri := protocol.DocumentURI("file:///test.cdc") - - _, err = server.getDiagnostics(uri, code, 1, func(*protocol.LogMessageParams) {}) - require.NoError(t, err) - - hover, err := server.Hover( - nil, - &protocol.TextDocumentPositionParams{ - TextDocument: protocol.TextDocumentIdentifier{URI: uri}, - Position: protocol.Position{Line: 2, Character: 15}, - }, - ) - require.NoError(t, err) - require.NotNil(t, hover) - - assert.Equal( - t, - &protocol.Hover{ - Range: protocol.Range{ - Start: protocol.Position{Line: 2, Character: 14}, - End: protocol.Position{Line: 2, Character: 17}, + t.Run("no docstring", func(t *testing.T) { + server, err := NewServer() + require.NoError(t, err) + + const code = ` + access(all) fun test() { + let foo = 1 + } + ` + + uri := protocol.DocumentURI("file:///test.cdc") + + _, err = server.getDiagnostics(uri, code, 1, func(*protocol.LogMessageParams) {}) + require.NoError(t, err) + + hover, err := server.Hover( + nil, + &protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri}, + Position: protocol.Position{Line: 2, Character: 19}, + }, + ) + require.NoError(t, err) + require.NotNil(t, hover) + + assert.Equal( + t, + &protocol.Hover{ + Range: protocol.Range{ + Start: protocol.Position{Line: 2, Character: 18}, + End: protocol.Position{Line: 2, Character: 21}, + }, + Contents: protocol.MarkupContent{ + Kind: protocol.Markdown, + Value: "**Type**\n\n```cadence\nInt\n```\n", + }, + }, + hover, + ) + }) + + t.Run("docstring with param and return annotations", func(t *testing.T) { + server, err := NewServer() + require.NoError(t, err) + + const code = ` + /// Adds two numbers. + /// + /// @param a: The first number + /// @param b: The second number + /// @return The sum + access(all) fun add(a: Int, b: Int): Int { + return a + b + } + + access(all) fun test() { + let result = add(a: 1, b: 2) + } + ` + + uri := protocol.DocumentURI("file:///test2.cdc") + + _, err = server.getDiagnostics(uri, code, 1, func(*protocol.LogMessageParams) {}) + require.NoError(t, err) + + // Hover over the `add` call + // Line 11 (0-indexed): " let result = add(a: 1, b: 2)" + // "add" starts at column 27 + hover, err := server.Hover( + nil, + &protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: uri}, + Position: protocol.Position{Line: 11, Character: 27}, }, - Contents: protocol.MarkupContent{ - Kind: protocol.Markdown, - Value: "**Type**\n\n```cadence\nInt\n```\n", + ) + require.NoError(t, err) + require.NotNil(t, hover) + + assert.Equal( + t, + protocol.MarkupContent{ + Kind: protocol.Markdown, + Value: "**Type**\n\n```cadence\n" + + "fun (a: Int, b: Int): Int\n```\n" + + "\n**Documentation**\n\n" + + "Adds two numbers.\n\n" + + "**Parameters**\n\n" + + "- `a`: The first number\n" + + "- `b`: The second number\n\n" + + "**Returns** The sum\n", }, - }, - hover, - ) + hover.Contents, + ) + }) } diff --git a/languageserver/server/server.go b/languageserver/server/server.go index 63497eda..b45b326b 100644 --- a/languageserver/server/server.go +++ b/languageserver/server/server.go @@ -857,7 +857,7 @@ func (s *Server) Hover( documentType(occurrence.Origin.Type), ) - docString := occurrence.Origin.DocString + docString := formatDocString(occurrence.Origin.DocString) if docString != "" { _, _ = fmt.Fprintf( &markup, @@ -1965,7 +1965,7 @@ func (s *Server) maybeResolveMember(uri protocol.DocumentURI, id string, result result.Documentation = &protocol.Or_CompletionItem_documentation{ Value: protocol.MarkupContent{ Kind: "markdown", - Value: member.DocString, + Value: formatDocString(member.DocString), }, } @@ -2045,7 +2045,7 @@ func (s *Server) maybeResolveRange(uri protocol.DocumentURI, id string, result * } result.Documentation = &protocol.Or_CompletionItem_documentation{ - Value: r.DocString, + Value: formatDocString(r.DocString), } return true