diff --git a/docs/language/conformance.md b/docs/language/conformance.md
index d788977..fb2ab4a 100644
--- a/docs/language/conformance.md
+++ b/docs/language/conformance.md
@@ -42,6 +42,19 @@ code appears among the diagnostics for that file. Diagnostic codes are the ones
registered in `internal/diagnostics/registry.go` and documented in
`docs/reference/diagnostic-codes.md`.
+## Scope and limits
+
+The corpus uses single-file `CheckSource`, so it pins what one file can verify
+without a project: package and metadata declarations, route forms, `view {}`
+markup, `style {}`, literal `build {}`, slots, and the rejection contracts below.
+
+It cannot cleanly cover constructs that require project context: reactive `g:`
+directives (`g:if`/`g:on`/`g:bind`) reference a Go-typed `state` contract that
+does not resolve single-file; endpoint forms (`act`/`api`) need exported Go
+handlers; and `layout`/`wasm`/`asset`/`css` need sibling files or config. Those
+are exercised by the package- and build-level tests instead. Expanding the
+corpus to a project-level harness for them is tracked separately.
+
## Coverage
`TestConformanceCorpusCoversRejectionContracts` fails when a rejection contract
diff --git a/docs/product/language-server.md b/docs/product/language-server.md
index 3cc1e26..48bcd4d 100644
--- a/docs/product/language-server.md
+++ b/docs/product/language-server.md
@@ -55,6 +55,9 @@ Developers editing `.gwdk` files need live feedback from the same language tooli
missing GOWDK `use` aliases.
- Return full-document semantic tokens for `.gwdk` decorators, identifiers,
strings, and operators.
+- Return a document outline (top-level package, metadata, imports, uses, blocks,
+ endpoints, and component/page declarations) from the recursive-descent outline
+ pass over the shared tokenizer.
### Non-Functional
@@ -78,6 +81,8 @@ Developers editing `.gwdk` files need live feedback from the same language tooli
- [x] `textDocument/references` returns open-document references for page IDs, routes, components, stores, and guards.
- [x] `textDocument/codeAction` returns quick fixes for old endpoint syntax and missing GOWDK use aliases.
- [x] `textDocument/semanticTokens/full` returns encoded token data for open `.gwdk` buffers.
+- [x] `textDocument/documentSymbol` returns a top-level outline parsed by the
+ recursive-descent outline pass over the shared tokenizer (ADR 0010).
- [x] `go test ./...` and `go build ./cmd/gowdk` pass.
## Edge Cases
diff --git a/internal/lang/lexer.go b/internal/lang/lexer.go
index d902b38..3dd95b6 100644
--- a/internal/lang/lexer.go
+++ b/internal/lang/lexer.go
@@ -4,19 +4,44 @@ import "unicode"
// Lex tokenizes .gwdk source for editor and CLI tooling.
func Lex(source string) ([]Token, Diagnostics) {
+ runes := []rune(source)
+ // byteOffsets[i] is the 0-based byte offset of rune i in the original
+ // source; the final entry is the total byte length. Offsets are taken from
+ // ranging the original string (which reports true byte positions) rather
+ // than summing utf8.RuneLen, so malformed UTF-8 — where []rune turns each
+ // bad byte into a 3-byte U+FFFD — does not drift token offsets.
+ byteOffsets := make([]int, len(runes)+1)
+ runeIndex := 0
+ for byteIndex := range source {
+ byteOffsets[runeIndex] = byteIndex
+ runeIndex++
+ }
+ byteOffsets[len(runes)] = len(source)
+
lexer := scanner{
- source: []rune(source),
- line: 1,
- column: 1,
+ source: runes,
+ byteOffsets: byteOffsets,
+ line: 1,
+ column: 1,
}
return lexer.scan()
}
type scanner struct {
- source []rune
- index int
- line int
- column int
+ source []rune
+ byteOffsets []int
+ index int
+ line int
+ column int
+}
+
+// offset returns the 0-based byte offset of the current rune in the original
+// source.
+func (scanner *scanner) offset() int {
+ if scanner.index < len(scanner.byteOffsets) {
+ return scanner.byteOffsets[scanner.index]
+ }
+ return scanner.byteOffsets[len(scanner.byteOffsets)-1]
}
func (scanner *scanner) scan() ([]Token, Diagnostics) {
@@ -26,13 +51,14 @@ func (scanner *scanner) scan() ([]Token, Diagnostics) {
for !scanner.done() {
ch := scanner.peek()
pos := scanner.position()
+ offset := scanner.offset()
switch {
case ch == '\r':
scanner.advance()
case ch == '\n':
scanner.advance()
- tokens = append(tokens, Token{Kind: TokenNewline, Lexeme: "\n", Pos: pos})
+ tokens = append(tokens, Token{Kind: TokenNewline, Lexeme: "\n", Pos: pos, Offset: offset})
case unicode.IsSpace(ch):
scanner.advance()
case ch == '/' && scanner.peekNext() == '/':
@@ -47,47 +73,49 @@ func (scanner *scanner) scan() ([]Token, Diagnostics) {
}
case ch == '{':
scanner.advance()
- tokens = append(tokens, Token{Kind: TokenLBrace, Lexeme: "{", Pos: pos})
+ tokens = append(tokens, Token{Kind: TokenLBrace, Lexeme: "{", Pos: pos, Offset: offset})
case ch == '}':
scanner.advance()
- tokens = append(tokens, Token{Kind: TokenRBrace, Lexeme: "}", Pos: pos})
+ tokens = append(tokens, Token{Kind: TokenRBrace, Lexeme: "}", Pos: pos, Offset: offset})
case ch == ',':
scanner.advance()
- tokens = append(tokens, Token{Kind: TokenComma, Lexeme: ",", Pos: pos})
+ tokens = append(tokens, Token{Kind: TokenComma, Lexeme: ",", Pos: pos, Offset: offset})
case ch == ':':
scanner.advance()
- tokens = append(tokens, Token{Kind: TokenColon, Lexeme: ":", Pos: pos})
+ tokens = append(tokens, Token{Kind: TokenColon, Lexeme: ":", Pos: pos, Offset: offset})
case ch == '?':
scanner.advance()
- tokens = append(tokens, Token{Kind: TokenQuestion, Lexeme: "?", Pos: pos})
+ tokens = append(tokens, Token{Kind: TokenQuestion, Lexeme: "?", Pos: pos, Offset: offset})
case ch == '=' && scanner.peekNext() == '>':
scanner.advance()
scanner.advance()
- tokens = append(tokens, Token{Kind: TokenArrow, Lexeme: "=>", Pos: pos})
+ tokens = append(tokens, Token{Kind: TokenArrow, Lexeme: "=>", Pos: pos, Offset: offset})
default:
tokens = append(tokens, scanner.text())
}
}
- tokens = append(tokens, Token{Kind: TokenEOF, Pos: scanner.position()})
+ tokens = append(tokens, Token{Kind: TokenEOF, Pos: scanner.position(), Offset: scanner.offset()})
return tokens, diagnostics
}
func (scanner *scanner) identifier() Token {
pos := scanner.position()
+ offset := scanner.offset()
start := scanner.index
for !scanner.done() && (isIdentPart(scanner.peek()) || scanner.peek() == '.' || scanner.peek() == '-') {
scanner.advance()
}
lexeme := string(scanner.source[start:scanner.index])
if scanner.isLineLeading(start) && isMetadataLexeme(lexeme) {
- return Token{Kind: TokenMetadata, Lexeme: lexeme, Pos: pos}
+ return Token{Kind: TokenMetadata, Lexeme: lexeme, Pos: pos, Offset: offset}
}
- return Token{Kind: TokenIdentifier, Lexeme: lexeme, Pos: pos}
+ return Token{Kind: TokenIdentifier, Lexeme: lexeme, Pos: pos, Offset: offset}
}
func (scanner *scanner) quotedString() (Token, Diagnostic) {
pos := scanner.position()
+ offset := scanner.offset()
start := scanner.index
scanner.advance()
for !scanner.done() {
@@ -101,14 +129,14 @@ func (scanner *scanner) quotedString() (Token, Diagnostic) {
}
if ch == '"' {
scanner.advance()
- return Token{Kind: TokenString, Lexeme: string(scanner.source[start:scanner.index]), Pos: pos}, Diagnostic{}
+ return Token{Kind: TokenString, Lexeme: string(scanner.source[start:scanner.index]), Pos: pos, Offset: offset}, Diagnostic{}
}
if ch == '\n' {
break
}
scanner.advance()
}
- return Token{Kind: TokenIllegal, Lexeme: string(scanner.source[start:scanner.index]), Pos: pos}, Diagnostic{
+ return Token{Kind: TokenIllegal, Lexeme: string(scanner.source[start:scanner.index]), Pos: pos, Offset: offset}, Diagnostic{
Pos: pos,
Range: sourceRange(pos, scanner.position()),
Code: "unterminated_string",
@@ -129,6 +157,7 @@ func sourceRange(start, end Position) *Range {
func (scanner *scanner) text() Token {
pos := scanner.position()
+ offset := scanner.offset()
start := scanner.index
for !scanner.done() {
ch := scanner.peek()
@@ -140,7 +169,7 @@ func (scanner *scanner) text() Token {
}
scanner.advance()
}
- return Token{Kind: TokenText, Lexeme: string(scanner.source[start:scanner.index]), Pos: pos}
+ return Token{Kind: TokenText, Lexeme: string(scanner.source[start:scanner.index]), Pos: pos, Offset: offset}
}
func (scanner *scanner) skipLineComment() {
diff --git a/internal/lang/lexer_offset_test.go b/internal/lang/lexer_offset_test.go
new file mode 100644
index 0000000..80b4e0e
--- /dev/null
+++ b/internal/lang/lexer_offset_test.go
@@ -0,0 +1,76 @@
+package lang
+
+import (
+ "testing"
+
+ "github.com/cssbruno/gowdk/internal/source"
+)
+
+// TestLexTokenOffsetsAreByteAccurate verifies the tokenizer records each token's
+// 0-based byte offset and that it stays consistent with the token's line/column
+// via the source conversion helpers, including across a multi-byte rune. This is
+// the substrate contract the recursive-descent parser (ADR 0010) depends on.
+func TestLexTokenOffsetsAreByteAccurate(t *testing.T) {
+ // The euro sign is three bytes, so byte offsets and rune columns diverge
+ // after it.
+ src := "page home\ntitle \"€\"\nroute \"/\"\n"
+ tokens, _ := Lex(src)
+
+ buffer := []byte(src)
+ for _, token := range tokens {
+ if token.Kind == TokenEOF {
+ continue
+ }
+ // The token's recorded byte offset must point at its lexeme in the
+ // source buffer.
+ if token.Offset < 0 || token.Offset > len(buffer) {
+ t.Fatalf("token %q offset %d out of bounds", token.Lexeme, token.Offset)
+ }
+ if token.Kind != TokenNewline && token.Lexeme != "" {
+ got := string(buffer[token.Offset : token.Offset+len(token.Lexeme)])
+ if got != token.Lexeme {
+ t.Fatalf("token %q at offset %d points at %q", token.Lexeme, token.Offset, got)
+ }
+ }
+ // The byte offset and the line/column must describe the same position.
+ want := source.SourcePosition{Line: token.Pos.Line, Column: token.Pos.Column}
+ if off := source.OffsetOf(buffer, want); off != token.Offset {
+ t.Fatalf("token %q: OffsetOf(line %d,col %d)=%d, token offset=%d",
+ token.Lexeme, token.Pos.Line, token.Pos.Column, off, token.Offset)
+ }
+ }
+}
+
+// TestLexTokenOffsetsSurviveMalformedUTF8 guards against offset drift after an
+// invalid byte: []rune turns a malformed byte into a 3-byte U+FFFD, so deriving
+// offsets from utf8.RuneLen would push every later token two bytes past its true
+// position. Offsets must stay anchored to the original byte buffer.
+func TestLexTokenOffsetsSurviveMalformedUTF8(t *testing.T) {
+ // "x" then a lone 0xff byte, a newline, then "y": bytes x=0, 0xff=1, \n=2, y=3.
+ src := "x\xff\ny"
+ buffer := []byte(src)
+ tokens, _ := Lex(src)
+
+ for _, token := range tokens {
+ // Offset and line/column must agree against the real buffer (OffsetOf
+ // ranges the bytes, so it reports true positions even past a bad byte).
+ want := source.SourcePosition{Line: token.Pos.Line, Column: token.Pos.Column}
+ if off := source.OffsetOf(buffer, want); off != token.Offset {
+ t.Fatalf("token %q (kind %s): OffsetOf=%d, token offset=%d", token.Lexeme, token.Kind, off, token.Offset)
+ }
+ }
+
+ // The trailing valid token must land at byte 3, not 5 (the drifted value).
+ var found bool
+ for _, token := range tokens {
+ if token.Kind == TokenIdentifier && token.Lexeme == "y" {
+ found = true
+ if token.Offset != 3 {
+ t.Fatalf("trailing token y offset = %d, want 3", token.Offset)
+ }
+ }
+ }
+ if !found {
+ t.Fatal("expected to find the trailing identifier token y")
+ }
+}
diff --git a/internal/lang/outline.go b/internal/lang/outline.go
new file mode 100644
index 0000000..7bfb2f4
--- /dev/null
+++ b/internal/lang/outline.go
@@ -0,0 +1,205 @@
+package lang
+
+import (
+ "strings"
+
+ "github.com/cssbruno/gowdk/internal/source"
+)
+
+// OutlineKind classifies a top-level .gwdk declaration for a document outline.
+type OutlineKind string
+
+const (
+ OutlineKindPackage OutlineKind = "package"
+ OutlineKindMetadata OutlineKind = "metadata"
+ OutlineKindImport OutlineKind = "import"
+ OutlineKindUse OutlineKind = "use"
+ OutlineKindBlock OutlineKind = "block"
+ OutlineKindEndpoint OutlineKind = "endpoint"
+ OutlineKindComponent OutlineKind = "component"
+ OutlineKindPage OutlineKind = "page"
+)
+
+// OutlineSymbol is one entry in a document outline.
+type OutlineSymbol struct {
+ Kind OutlineKind
+ Name string
+ Detail string
+ Span source.SourceSpan
+}
+
+// Outline parses the top-level declaration structure of .gwdk source into a flat
+// document outline. It is a recursive-descent pass over the shared tokenizer —
+// the first consumer of the ADR 0010 parser direction — and recovers from
+// unrecognized lines by skipping to the next line, so a malformed line never
+// hides the rest of the outline. Block ranges span to the matching close brace,
+// counted over tokens (string literals are single tokens, so braces inside
+// strings never miscount).
+func Outline(src string) []OutlineSymbol {
+ tokens, _ := Lex(src)
+ var symbols []OutlineSymbol
+
+ index := 0
+ for index < len(tokens) {
+ token := tokens[index]
+ if token.Kind == TokenEOF {
+ break
+ }
+ if token.Kind == TokenNewline {
+ index++
+ continue
+ }
+
+ lineEnd, hasBrace := lineExtent(tokens, index)
+ line := tokens[index:lineEnd]
+
+ if hasBrace {
+ closeIndex := matchBrace(tokens, index)
+ symbols = append(symbols, OutlineSymbol{
+ Kind: OutlineKindBlock,
+ Name: blockName(line),
+ Span: spanOf(tokens[index], tokens[closeIndex]),
+ })
+ index = closeIndex + 1
+ continue
+ }
+
+ if symbol, ok := classifyLine(line); ok {
+ symbols = append(symbols, symbol)
+ }
+ index = lineEnd
+ }
+
+ return symbols
+}
+
+// lineExtent returns the index that ends the logical line starting at from (the
+// next newline or EOF) and whether the line contains a block-opening brace.
+func lineExtent(tokens []Token, from int) (int, bool) {
+ hasBrace := false
+ index := from
+ for index < len(tokens) && tokens[index].Kind != TokenNewline && tokens[index].Kind != TokenEOF {
+ if tokens[index].Kind == TokenLBrace {
+ hasBrace = true
+ }
+ index++
+ }
+ return index, hasBrace
+}
+
+// matchBrace returns the index of the close brace that balances the first open
+// brace at or after from. An unbalanced block recovers to the last token before
+// EOF so the outline still terminates.
+func matchBrace(tokens []Token, from int) int {
+ depth := 0
+ for index := from; index < len(tokens); index++ {
+ switch tokens[index].Kind {
+ case TokenLBrace:
+ depth++
+ case TokenRBrace:
+ depth--
+ if depth == 0 {
+ return index
+ }
+ case TokenEOF:
+ if index > from {
+ return index - 1
+ }
+ return index
+ }
+ }
+ return len(tokens) - 1
+}
+
+func blockName(line []Token) string {
+ var parts []string
+ for _, token := range line {
+ if token.Kind == TokenLBrace {
+ break
+ }
+ if token.Kind == TokenIdentifier || token.Kind == TokenMetadata {
+ parts = append(parts, token.Lexeme)
+ }
+ }
+ return strings.Join(parts, " ")
+}
+
+func classifyLine(line []Token) (OutlineSymbol, bool) {
+ first := line[0]
+ span := spanOf(first, line[len(line)-1])
+
+ switch {
+ case first.Kind == TokenIdentifier && first.Lexeme == "package":
+ return OutlineSymbol{Kind: OutlineKindPackage, Name: "package " + nextLexeme(line, 0), Span: span}, true
+ case first.Kind == TokenIdentifier && first.Lexeme == "import":
+ return OutlineSymbol{Kind: OutlineKindImport, Name: "import", Detail: lineValue(line, 1), Span: span}, true
+ case first.Kind == TokenIdentifier && first.Lexeme == "use":
+ return OutlineSymbol{Kind: OutlineKindUse, Name: "use " + nextLexeme(line, 0), Detail: lineValue(line, 2), Span: span}, true
+ case first.Kind == TokenIdentifier && (first.Lexeme == "act" || first.Lexeme == "api"):
+ return OutlineSymbol{Kind: OutlineKindEndpoint, Name: first.Lexeme + " " + nextLexeme(line, 0), Detail: lineValue(line, 2), Span: span}, true
+ case first.Kind == TokenMetadata:
+ return classifyMetadata(first, line, span), true
+ default:
+ return OutlineSymbol{}, false
+ }
+}
+
+func classifyMetadata(first Token, line []Token, span source.SourceSpan) OutlineSymbol {
+ name := nextLexeme(line, 0)
+ switch first.Lexeme {
+ case "component":
+ if name != "" {
+ return OutlineSymbol{Kind: OutlineKindComponent, Name: "component " + name, Span: span}
+ }
+ case "page":
+ if name != "" {
+ return OutlineSymbol{Kind: OutlineKindPage, Name: "page " + name, Span: span}
+ }
+ }
+ return OutlineSymbol{Kind: OutlineKindMetadata, Name: first.Lexeme, Detail: lineValue(line, 1), Span: span}
+}
+
+// nextLexeme returns the lexeme of the first identifier or string after position
+// at in the line, unquoted.
+func nextLexeme(line []Token, at int) string {
+ for index := at + 1; index < len(line); index++ {
+ switch line[index].Kind {
+ case TokenIdentifier, TokenText:
+ return line[index].Lexeme
+ case TokenString:
+ return unquote(line[index].Lexeme)
+ }
+ }
+ return ""
+}
+
+// lineValue joins the lexemes from position at to the end of the line into a
+// short detail string.
+func lineValue(line []Token, at int) string {
+ var parts []string
+ for index := at; index < len(line); index++ {
+ lexeme := line[index].Lexeme
+ if line[index].Kind == TokenString {
+ lexeme = unquote(lexeme)
+ }
+ if strings.TrimSpace(lexeme) != "" {
+ parts = append(parts, lexeme)
+ }
+ }
+ return strings.Join(parts, " ")
+}
+
+func unquote(lexeme string) string {
+ return strings.Trim(lexeme, "\"")
+}
+
+func spanOf(first, last Token) source.SourceSpan {
+ return source.SourceSpan{
+ Start: source.SourcePosition{Line: first.Pos.Line, Column: first.Pos.Column, Offset: first.Offset},
+ End: source.SourcePosition{
+ Line: last.Pos.Line,
+ Column: last.Pos.Column + len([]rune(last.Lexeme)),
+ Offset: last.Offset + len(last.Lexeme),
+ },
+ }
+}
diff --git a/internal/lang/outline_test.go b/internal/lang/outline_test.go
new file mode 100644
index 0000000..db54372
--- /dev/null
+++ b/internal/lang/outline_test.go
@@ -0,0 +1,101 @@
+package lang
+
+import "testing"
+
+func symbolNames(symbols []OutlineSymbol) []string {
+ names := make([]string, 0, len(symbols))
+ for _, symbol := range symbols {
+ names = append(names, symbol.Name)
+ }
+ return names
+}
+
+func findSymbol(symbols []OutlineSymbol, name string) (OutlineSymbol, bool) {
+ for _, symbol := range symbols {
+ if symbol.Name == name {
+ return symbol, true
+ }
+ }
+ return OutlineSymbol{}, false
+}
+
+func TestOutlineParsesTopLevelDeclarations(t *testing.T) {
+ src := `package pages
+
+route "/"
+title "Home"
+
+view {
+
+ {title}
+
+}
+
+style {
+ main { padding: 1rem; }
+}
+`
+ symbols := Outline(src)
+
+ for _, want := range []string{"package pages", "route", "title", "view", "style"} {
+ if _, ok := findSymbol(symbols, want); !ok {
+ t.Errorf("expected outline symbol %q; got %v", want, symbolNames(symbols))
+ }
+ }
+
+ // The view block's range must extend past the interpolation braces to the
+ // real closing brace, not stop at the {title} interpolation.
+ view, _ := findSymbol(symbols, "view")
+ if view.Span.End.Line < 10 {
+ t.Fatalf("view block range ended too early at line %d (interpolation miscounted?)", view.Span.End.Line)
+ }
+}
+
+func TestOutlineRecoversFromUnknownLines(t *testing.T) {
+ // A junk line sits between valid declarations; the parser must skip it and
+ // still surface the declarations after it.
+ src := `package pages
+
+@@@ not valid !!!
+
+route "/"
+
+view {
+
+}
+`
+ symbols := Outline(src)
+ for _, want := range []string{"package pages", "route", "view"} {
+ if _, ok := findSymbol(symbols, want); !ok {
+ t.Errorf("recovery failed: expected %q after a junk line; got %v", want, symbolNames(symbols))
+ }
+ }
+}
+
+func TestOutlineIncludesEndpointsAndComponents(t *testing.T) {
+ src := `package widgets
+
+component Counter
+
+api Items GET "/items"
+`
+ symbols := Outline(src)
+ if symbol, ok := findSymbol(symbols, "component Counter"); !ok || symbol.Kind != OutlineKindComponent {
+ t.Errorf("expected a component symbol; got %v", symbolNames(symbols))
+ }
+ if symbol, ok := findSymbol(symbols, "api Items"); !ok || symbol.Kind != OutlineKindEndpoint {
+ t.Errorf("expected an endpoint symbol; got %v", symbolNames(symbols))
+ }
+}
+
+func TestOutlineSpansCarryOffsets(t *testing.T) {
+ src := "package pages\nroute \"/\"\n"
+ symbols := Outline(src)
+ pkg, ok := findSymbol(symbols, "package pages")
+ if !ok {
+ t.Fatalf("expected package symbol; got %v", symbolNames(symbols))
+ }
+ if pkg.Span.Start.Offset != 0 {
+ t.Fatalf("package symbol start offset = %d, want 0", pkg.Span.Start.Offset)
+ }
+}
diff --git a/internal/lang/testdata/conformance/accept/slot_component.cmp.gwdk b/internal/lang/testdata/conformance/accept/slot_component.cmp.gwdk
new file mode 100644
index 0000000..8bfb487
--- /dev/null
+++ b/internal/lang/testdata/conformance/accept/slot_component.cmp.gwdk
@@ -0,0 +1,14 @@
+package components
+
+component Card
+
+props {
+ title string
+}
+
+view {
+
+}
diff --git a/internal/lang/testdata/conformance/accept/styled_page.gwdk b/internal/lang/testdata/conformance/accept/styled_page.gwdk
new file mode 100644
index 0000000..c535cf1
--- /dev/null
+++ b/internal/lang/testdata/conformance/accept/styled_page.gwdk
@@ -0,0 +1,15 @@
+package pages
+
+route "/styled"
+title "Styled"
+
+style {
+ main { padding: 1rem; }
+ h1 { color: #222; }
+}
+
+view {
+
+ Styled
+
+}
diff --git a/internal/lang/token.go b/internal/lang/token.go
index 7a176c5..9532b95 100644
--- a/internal/lang/token.go
+++ b/internal/lang/token.go
@@ -19,9 +19,13 @@ const (
TokenText TokenKind = "text"
)
-// Token is a lexical token with source location.
+// Token is a lexical token with source location. Offset is the 0-based byte
+// offset of the token start in the source, the exact substrate the planned
+// recursive-descent parser (ADR 0010) uses to build spans without re-deriving
+// positions from line/column.
type Token struct {
Kind TokenKind
Lexeme string
Pos Position
+ Offset int
}
diff --git a/internal/lsp/document_symbols.go b/internal/lsp/document_symbols.go
new file mode 100644
index 0000000..bb3b2cd
--- /dev/null
+++ b/internal/lsp/document_symbols.go
@@ -0,0 +1,55 @@
+package lsp
+
+import "github.com/cssbruno/gowdk/internal/lang"
+
+// LSP SymbolKind values (subset) from the language server protocol.
+const (
+ symbolKindModule = 2
+ symbolKindPackage = 4
+ symbolKindClass = 5
+ symbolKindMethod = 6
+ symbolKindProperty = 7
+ symbolKindField = 8
+)
+
+// documentSymbols returns the top-level outline of a .gwdk document, parsed by
+// the recursive-descent outline pass over the shared tokenizer.
+func (server *Server) documentSymbols(params documentSymbolParams) []documentSymbol {
+ doc, ok := server.documents[params.TextDocument.URI]
+ if !ok {
+ return []documentSymbol{}
+ }
+
+ outline := lang.Outline(doc.Text)
+ symbols := make([]documentSymbol, 0, len(outline))
+ for _, item := range outline {
+ rng := lspRangeFromSourceSpan(item.Span, doc.Text)
+ symbols = append(symbols, documentSymbol{
+ Name: item.Name,
+ Detail: item.Detail,
+ Kind: outlineSymbolKind(item.Kind),
+ Range: rng,
+ SelectionRange: rng,
+ })
+ }
+ return symbols
+}
+
+func outlineSymbolKind(kind lang.OutlineKind) int {
+ switch kind {
+ case lang.OutlineKindPackage:
+ return symbolKindPackage
+ case lang.OutlineKindComponent:
+ return symbolKindClass
+ case lang.OutlineKindPage:
+ return symbolKindModule
+ case lang.OutlineKindEndpoint:
+ return symbolKindMethod
+ case lang.OutlineKindBlock:
+ return symbolKindField
+ case lang.OutlineKindImport, lang.OutlineKindUse:
+ return symbolKindModule
+ default:
+ return symbolKindProperty
+ }
+}
diff --git a/internal/lsp/protocol_types.go b/internal/lsp/protocol_types.go
index 6fbc816..f675dc6 100644
--- a/internal/lsp/protocol_types.go
+++ b/internal/lsp/protocol_types.go
@@ -49,10 +49,24 @@ type serverCapabilities struct {
ReferencesProvider bool `json:"referencesProvider"`
CodeActionProvider bool `json:"codeActionProvider"`
DocumentFormattingProvider bool `json:"documentFormattingProvider"`
+ DocumentSymbolProvider bool `json:"documentSymbolProvider"`
CompletionProvider completionOptions `json:"completionProvider"`
SemanticTokensProvider semanticTokensOptions `json:"semanticTokensProvider"`
}
+type documentSymbolParams struct {
+ TextDocument textDocumentIdentifier `json:"textDocument"`
+}
+
+type documentSymbol struct {
+ Name string `json:"name"`
+ Detail string `json:"detail,omitempty"`
+ Kind int `json:"kind"`
+ Range lspRange `json:"range"`
+ SelectionRange lspRange `json:"selectionRange"`
+ Children []documentSymbol `json:"children,omitempty"`
+}
+
type textDocumentSyncOptions struct {
OpenClose bool `json:"openClose"`
Change int `json:"change"`
diff --git a/internal/lsp/requests.go b/internal/lsp/requests.go
index 5448a72..c5c2bd5 100644
--- a/internal/lsp/requests.go
+++ b/internal/lsp/requests.go
@@ -21,6 +21,7 @@ func (server *Server) handleRequest(request rpcRequest) [][]byte {
ReferencesProvider: true,
CodeActionProvider: true,
DocumentFormattingProvider: true,
+ DocumentSymbolProvider: true,
CompletionProvider: completionOptions{
TriggerCharacters: []string{"@", ":", "<", " "},
},
@@ -96,6 +97,12 @@ func (server *Server) handleRequest(request rpcRequest) [][]byte {
return singleMessage(errorResponse(request.ID, invalidParams, err.Error()))
}
return singleMessage(response(request.ID, server.semanticTokens(params)))
+ case "textDocument/documentSymbol":
+ var params documentSymbolParams
+ if err := decodeParams(request.Params, ¶ms); err != nil {
+ return singleMessage(errorResponse(request.ID, invalidParams, err.Error()))
+ }
+ return singleMessage(response(request.ID, server.documentSymbols(params)))
default:
return singleMessage(errorResponse(request.ID, methodNotFound, fmt.Sprintf("method not found: %s", request.Method)))
}
diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go
index 71e08e6..3d176d5 100644
--- a/internal/lsp/server_test.go
+++ b/internal/lsp/server_test.go
@@ -463,6 +463,44 @@ func TestServerReturnsSemanticTokens(t *testing.T) {
})
}
+func TestServerReturnsDocumentSymbols(t *testing.T) {
+ uri := "file:///tmp/home.page.gwdk"
+ input := framed(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`) +
+ framed(`{"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"`+uri+`","languageId":"gwdk","version":1,"text":"package app\n\nroute \"/\"\ntitle \"Home\"\n\nview {\n \n}\n"}}}`) +
+ framed(`{"jsonrpc":"2.0","id":2,"method":"textDocument/documentSymbol","params":{"textDocument":{"uri":"`+uri+`"}}}`) +
+ framed(`{"jsonrpc":"2.0","id":3,"method":"shutdown","params":null}`) +
+ framed(`{"jsonrpc":"2.0","method":"exit"}`)
+
+ var output bytes.Buffer
+ server := NewServer(gowdk.Config{})
+ server.log = nil
+ if err := server.Serve(stringsReader(input), &output); err != nil {
+ t.Fatal(err)
+ }
+
+ messages := readOutputMessages(t, output.Bytes())
+ capabilities := messages[0]["result"].(map[string]any)["capabilities"].(map[string]any)
+ if capabilities["documentSymbolProvider"] != true {
+ t.Fatalf("expected documentSymbolProvider capability, got %#v", capabilities["documentSymbolProvider"])
+ }
+
+ assertResponseID(t, messages[2], float64(2))
+ result, ok := messages[2]["result"].([]any)
+ if !ok {
+ t.Fatalf("expected a document-symbol array, got %#v", messages[2]["result"])
+ }
+ names := map[string]bool{}
+ for _, item := range result {
+ symbol := item.(map[string]any)
+ names[symbol["name"].(string)] = true
+ }
+ for _, want := range []string{"package app", "route", "title", "view"} {
+ if !names[want] {
+ t.Fatalf("expected document symbol %q, got %#v", want, names)
+ }
+ }
+}
+
func TestServerReturnsMethodNotFoundForUnknownRequests(t *testing.T) {
input := framed(`{"jsonrpc":"2.0","id":"x","method":"gowdk/unknown","params":{}}`) +
framed(`{"jsonrpc":"2.0","method":"exit"}`)