From a08f354a5756cfee931ad8dc92176fdf22099aa6 Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Tue, 21 Apr 2026 10:48:40 +0600 Subject: [PATCH 1/6] LSP: Hover on attr --- internal/frontend/parser/attributes.go | 11 +-- internal/lsp/server.go | 51 +++++++++++++- internal/lsp/server_test.go | 98 ++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 8 deletions(-) diff --git a/internal/frontend/parser/attributes.go b/internal/frontend/parser/attributes.go index e9e94692..169ff8a4 100644 --- a/internal/frontend/parser/attributes.go +++ b/internal/frontend/parser/attributes.go @@ -5,14 +5,15 @@ type DeclAttributeSpec struct { FunctionOnly bool MaxArgs int NoArgsMessage string + Doc string } var declAttributeSpecs = []DeclAttributeSpec{ - {Name: "extern", FunctionOnly: true, MaxArgs: 1}, - {Name: "builtin", FunctionOnly: true, MaxArgs: 1}, - {Name: "allow_unused", MaxArgs: 0, NoArgsMessage: "#[allow_unused] does not accept arguments"}, - {Name: "if", MaxArgs: -1}, - {Name: "ifnot", MaxArgs: -1}, + {Name: "extern", FunctionOnly: true, MaxArgs: 1, Doc: "Marks a function as externally linked. Optional argument overrides the linked symbol name."}, + {Name: "builtin", FunctionOnly: true, MaxArgs: 1, Doc: "Marks a function as compiler/runtime-provided. Optional argument overrides the linked symbol name."}, + {Name: "allow_unused", MaxArgs: 0, NoArgsMessage: "#[allow_unused] does not accept arguments", Doc: "Suppresses unused diagnostics for the annotated declaration."}, + {Name: "if", MaxArgs: -1, Doc: "Includes the annotated declaration only when the compile-time condition matches."}, + {Name: "ifnot", MaxArgs: -1, Doc: "Includes the annotated declaration only when the compile-time condition does not match."}, } var declAttributeSpecByName = buildDeclAttributeSpecByName(declAttributeSpecs) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 4fb4ad57..0ef3d766 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -77,7 +77,12 @@ var ( if name == "" { continue } - items = append(items, completionItem{Label: name, Kind: completionKindProperty}) + item := completionItem{Label: name, Kind: completionKindProperty} + if spec, ok := parser.LookupDeclAttributeSpec(name); ok { + item.Detail = attributeSignature(spec) + item.Documentation = spec.Doc + } + items = append(items, item) } return items }() @@ -1904,9 +1909,51 @@ func collectHoverCandidates(mod *context.Module, info *typeinfo.ModuleInfo, modu } out = append(out, collectOwnershipCastSyntaxHoverCandidates(mod, info)...) out = append(out, collectKeywordHoverCandidates(parsedPath, sourceText)...) + out = append(out, collectAttributeHoverCandidates(parsedPath, sourceText)...) return out, defs } +func collectAttributeHoverCandidates(path, sourceText string) []hoverCandidate { + if path == "" || sourceText == "" { + return nil + } + diag := diagnostics.NewDiagnosticBag("lsp_attribute_hover") + toks := lexSource(path, sourceText, diag) + if len(toks) == 0 { + return nil + } + out := make([]hoverCandidate, 0) + for i := 0; i+2 < len(toks); i++ { + if toks[i].Kind != tokens.HASH || toks[i+1].Kind != tokens.LBRACK || toks[i+2].Kind != tokens.IDENT { + continue + } + name := toks[i+2].Literal + spec, ok := parser.LookupDeclAttributeSpec(name) + if !ok || spec.Doc == "" { + continue + } + loc := source.NewLocation(path, toks[i+2].Start, toks[i+2].End) + out = append(out, hoverCandidate{ + markdown: appendHoverDoc(asFerretCodeBlock(attributeSignature(spec)), spec.Doc), + location: loc, + span: locationSpan(loc), + priority: 3, + }) + } + return out +} + +func attributeSignature(spec parser.DeclAttributeSpec) string { + switch spec.MaxArgs { + case 0: + return "#[" + spec.Name + "]" + case 1: + return "#[" + spec.Name + "(...)]" + default: + return "#[" + spec.Name + "(...)]" + } +} + func collectKeywordHoverCandidates(path, sourceText string) []hoverCandidate { if path == "" || sourceText == "" { return nil @@ -3075,8 +3122,6 @@ func normalizeLocationFile(loc source.Location, parsedPath, originalPath string) candidate := "" if loc.Filename != nil { candidate = *loc.Filename - } else if *loc.Filename != "" { - candidate = *loc.Filename } if candidate == "" || !sameFilePath(candidate, parsedPath) { return loc diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index ed6d9b88..fb9bc7e7 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -614,6 +614,45 @@ func TestHoverReturnsKeywordDocumentation(t *testing.T) { } } +func TestHoverReturnsAttributeDocumentation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "main.fer") + src := "#[allow_unused]\nfn helper() {\n}\n" + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatalf("failed to write source: %v", err) + } + + line, char, ok := findPosition(src, "allow_unused") + if !ok { + t.Fatal("failed to find attribute name") + } + + var out bytes.Buffer + uri := "file://" + filepath.ToSlash(path) + s := &Server{out: &out, documents: make(map[string]openDocument), hoverCache: make(map[string]hoverCacheEntry)} + req := rpcRequest{ + JSONRPC: "2.0", + ID: json.RawMessage("1"), + Method: "textDocument/hover", + Params: mustRawJSON(t, hoverParams{ + TextDocument: textDocumentIdentifier{URI: uri}, + Position: lspPosition{Line: line, Character: char}, + }), + } + s.handleRequest(req) + + hover := decodeHoverResult(t, out.String()) + if hover == nil { + t.Fatal("expected hover result") + } + if !strings.Contains(hover.Contents.Value, "```ferret\n#[allow_unused]\n```") { + t.Fatalf("expected attribute code block in hover, got %q", hover.Contents.Value) + } + if !strings.Contains(hover.Contents.Value, "Suppresses unused diagnostics") { + t.Fatalf("expected attribute documentation in hover, got %q", hover.Contents.Value) + } +} + func TestHoverRangeExpressionShowsSourceSyntaxWithoutType(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "main.fer") @@ -3641,6 +3680,65 @@ func TestCompletionSuggestsFerretAttributesInAttributeContext(t *testing.T) { } } +func TestCompletionSuggestsStructLiteralFields(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "main.fer") + src := "type User struct {\n name: str\n age: i32\n}\n\nfn (self: User) label() -> str {\n return self.name\n}\n\nfn main() {\n let user = User{\n \n }\n user\n}\n" + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatalf("failed to write source: %v", err) + } + + line, _, ok := findPosition(src, " let user = User{") + if !ok { + t.Fatal("failed to find struct literal completion position") + } + line++ + char := len(" ") + + var out bytes.Buffer + uri := "file://" + filepath.ToSlash(path) + s := &Server{out: &out, documents: make(map[string]openDocument), hoverCache: make(map[string]hoverCacheEntry)} + req := rpcRequest{ + JSONRPC: "2.0", + ID: json.RawMessage("1"), + Method: "textDocument/completion", + Params: mustRawJSON(t, completionParams{ + TextDocument: textDocumentIdentifier{URI: uri}, + Position: lspPosition{Line: line, Character: char}, + }), + } + s.handleRequest(req) + + items := decodeCompletionResult(t, out.String()) + foundName := false + foundAge := false + foundIf := false + foundMethod := false + for _, item := range items { + if item.Label == "name" && item.Kind == completionKindField { + foundName = true + } + if item.Label == "age" && item.Kind == completionKindField { + foundAge = true + } + if item.Label == "if" { + foundIf = true + } + if item.Label == "label" { + foundMethod = true + } + } + if !foundName || !foundAge { + t.Fatalf("expected struct field completions, got %#v", items) + } + if foundIf { + t.Fatalf("did not expect keyword completion in struct literal field context, got %#v", items) + } + if foundMethod { + t.Fatalf("did not expect method completion in struct literal field context, got %#v", items) + } +} + func TestCompletionIncludesPreludeGlobalsWithoutImport(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "main.fer") From 2840b37deefe2bb69fdee6736041c39403f95b39 Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Tue, 21 Apr 2026 11:44:53 +0600 Subject: [PATCH 2/6] Add struct literal field completions --- internal/lsp/server.go | 191 +++++++++++++++++++++++++++++++----- internal/lsp/server_test.go | 51 ++++++++++ 2 files changed, 215 insertions(+), 27 deletions(-) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 0ef3d766..6b67fa5e 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -2304,7 +2304,9 @@ func completionFromIndex(index *hoverIndex, sourceText string, pos source.Positi ctx := completionContextAt(sourceText, pos) candidates := visibleCompletionCandidatesAt(comp.items, comp.mod.FilePath, pos) items := make([]completionItem, 0) - if ctx.IsAttribute { + if literalItems, ok := structLiteralCompletionItemsForContext(comp, sourceText, pos); ok { + items = literalItems + } else if ctx.IsAttribute { items = append(items, attributeItems...) } else if ctx.IsMember { items = append(items, memberCompletionItemsForContext(comp, candidates, sourceText, pos)...) @@ -2549,6 +2551,111 @@ func completionContextAt(sourceText string, pos source.Position) completionConte return completionContext{} } +func structLiteralCompletionItemsForContext(index *completionIndex, sourceText string, pos source.Position) ([]completionItem, bool) { + if index == nil || index.mod == nil || index.mod.Types == nil { + return nil, false + } + var best *typeinfo.NamedType + bestSpan := int(^uint(0) >> 1) + for node, typ := range index.mod.Types.Nodes { + lit, ok := node.(*ast.CompositeLit) + if !ok || lit == nil || lit.Type == nil { + continue + } + if !locationContainsPosition(lit.Location, pos) { + continue + } + if _, ok := structLiteralFieldPrefix(sourceText, lit, pos); !ok { + continue + } + named := underlyingNamedType(typ) + if named == nil { + continue + } + span := locationSpan(lit.Location) + if span < bestSpan { + best = named + bestSpan = span + } + } + if best == nil { + return nil, false + } + return structFieldCompletionItemsForNamed(index.mod, index.modules, best), true +} + +func structLiteralFieldPrefix(sourceText string, lit *ast.CompositeLit, pos source.Position) (string, bool) { + offset := sourceOffsetAtPosition(sourceText, pos) + if offset < 0 || offset > len(sourceText) || lit == nil || lit.Location.Start == nil { + return "", false + } + start := lit.Location.Start.Index + if start < 0 || start > offset || start >= len(sourceText) { + return "", false + } + bodyStart := strings.IndexByte(sourceText[start:offset], '{') + if bodyStart < 0 { + return "", false + } + body := sourceText[start+bodyStart+1 : offset] + itemStart := 0 + assigned := false + depth := 0 + var quote byte + escaped := false + for i := 0; i < len(body); i++ { + ch := body[i] + if quote != 0 { + if escaped { + escaped = false + continue + } + if ch == '\\' { + escaped = true + continue + } + if ch == quote { + quote = 0 + } + continue + } + switch ch { + case '"', '\'': + quote = ch + case '(', '[', '{': + depth++ + case ')', ']', '}': + if depth > 0 { + depth-- + } + case ',': + if depth == 0 { + itemStart = i + 1 + assigned = false + } + case '=': + if depth == 0 { + assigned = true + } + } + } + if assigned { + return "", false + } + item := strings.TrimSpace(body[itemStart:]) + if item == "" { + return "", true + } + if !strings.HasPrefix(item, ".") { + return "", false + } + prefix := strings.TrimSpace(strings.TrimPrefix(item, ".")) + if prefix == "" || identExactPattern.MatchString(prefix) { + return prefix, true + } + return "", false +} + func sourceOffsetAtPosition(sourceText string, pos source.Position) int { if sourceText == "" { return 0 @@ -2699,32 +2806,7 @@ func instanceMemberCompletionItems(mod *context.Module, modulesByKey map[string] } resolved := declTypeForNamed(owner, named, decl) if structType, ok := resolved.(*typeinfo.StructType); ok && structType != nil { - if decl != nil { - if declStruct, ok := decl.Type.(*ast.StructType); ok && declStruct != nil { - for _, field := range declStruct.Fields { - if field == nil || field.Name == nil { - continue - } - fieldType := structFieldTypeByName(structType, field.Name.Text()) - items = append(items, completionItem{ - Label: field.Name.Text(), - Kind: completionKindField, - Detail: typeinfo.DefaultPrinter.Type(fieldType), - }) - } - } - } else { - for _, field := range structType.OrderedFields { - if field == nil || field.Name == "" { - continue - } - items = append(items, completionItem{ - Label: field.Name, - Kind: completionKindField, - Detail: typeinfo.DefaultPrinter.Type(field.Type), - }) - } - } + items = append(items, structFieldCompletionItems(decl, structType)...) } if owner.MethodSets != nil { for receiver, methods := range owner.MethodSets { @@ -2747,6 +2829,61 @@ func instanceMemberCompletionItems(mod *context.Module, modulesByKey map[string] return items } +func structFieldCompletionItemsForNamed(mod *context.Module, modulesByKey map[string]*context.Module, named *typeinfo.NamedType) []completionItem { + if named == nil { + return nil + } + owner := moduleForNamedType(named, mod, modulesByKey) + if owner == nil { + return nil + } + decl := named.Decl + if decl == nil { + decl = findTypeDecl(owner, named.Name) + } + resolved := declTypeForNamed(owner, named, decl) + structType, ok := resolved.(*typeinfo.StructType) + if !ok || structType == nil { + return nil + } + return structFieldCompletionItems(decl, structType) +} + +func structFieldCompletionItems(decl *ast.TypeDecl, structType *typeinfo.StructType) []completionItem { + if structType == nil { + return nil + } + if decl != nil { + if declStruct, ok := decl.Type.(*ast.StructType); ok && declStruct != nil { + items := make([]completionItem, 0, len(declStruct.Fields)) + for _, field := range declStruct.Fields { + if field == nil || field.Name == nil { + continue + } + fieldType := structFieldTypeByName(structType, field.Name.Text()) + items = append(items, completionItem{ + Label: field.Name.Text(), + Kind: completionKindField, + Detail: typeinfo.DefaultPrinter.Type(fieldType), + }) + } + return items + } + } + items := make([]completionItem, 0, len(structType.OrderedFields)) + for _, field := range structType.OrderedFields { + if field == nil || field.Name == "" { + continue + } + items = append(items, completionItem{ + Label: field.Name, + Kind: completionKindField, + Detail: typeinfo.DefaultPrinter.Type(field.Type), + }) + } + return items +} + func filterCompletionItemsByPrefix(items []completionItem, prefix string) []completionItem { prefix = strings.TrimSpace(prefix) if prefix == "" { diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index fb9bc7e7..c4bc69a4 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -3739,6 +3739,57 @@ func TestCompletionSuggestsStructLiteralFields(t *testing.T) { } } +func TestCompletionSuggestsStructLiteralFieldsAfterDot(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "main.fer") + src := "type User struct {\n name: str\n age: i32\n}\n\nfn main() {\n let user = User{\n .\n }\n user\n}\n" + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatalf("failed to write source: %v", err) + } + + line, char, ok := findPosition(src, " .") + if !ok { + t.Fatal("failed to find struct literal field completion position") + } + char += len(" .") + + var out bytes.Buffer + uri := "file://" + filepath.ToSlash(path) + s := &Server{out: &out, documents: make(map[string]openDocument), hoverCache: make(map[string]hoverCacheEntry)} + req := rpcRequest{ + JSONRPC: "2.0", + ID: json.RawMessage("1"), + Method: "textDocument/completion", + Params: mustRawJSON(t, completionParams{ + TextDocument: textDocumentIdentifier{URI: uri}, + Position: lspPosition{Line: line, Character: char}, + }), + } + s.handleRequest(req) + + items := decodeCompletionResult(t, out.String()) + foundName := false + foundAge := false + foundIf := false + for _, item := range items { + if item.Label == "name" && item.Kind == completionKindField { + foundName = true + } + if item.Label == "age" && item.Kind == completionKindField { + foundAge = true + } + if item.Label == "if" { + foundIf = true + } + } + if !foundName || !foundAge { + t.Fatalf("expected struct field completions after dot, got %#v", items) + } + if foundIf { + t.Fatalf("did not expect keyword completion in struct literal field context, got %#v", items) + } +} + func TestCompletionIncludesPreludeGlobalsWithoutImport(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "main.fer") From 5cd473d0dc5a7a048a56d7c4dfbb08603bb3223f Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Tue, 21 Apr 2026 21:36:03 +0600 Subject: [PATCH 3/6] Add struct literal completion and remove maxInt64 Collect struct-literal completion candidates into completionIndex to pick best span at completion time. Change struct literal prefix check to accept source.Location. Replace local maxInt64 usage with existing max and drop the helper. Add dry_test.fer and fix import. Rules check: - wrapper added: no - duplicated logic: no - helper added: collectStructLiteralCompletionCandidates to centralize candidate collection (allowed by RULES.md) --- dry_test.fer | 44 +++++++++++++ internal/analysis/layout/analysis/analyze.go | 21 ++---- internal/ir/hir/generate_test.go | 2 +- internal/lsp/server.go | 68 +++++++++++++------- 4 files changed, 98 insertions(+), 37 deletions(-) create mode 100644 dry_test.fer diff --git a/dry_test.fer b/dry_test.fer new file mode 100644 index 00000000..cc192e52 --- /dev/null +++ b/dry_test.fer @@ -0,0 +1,44 @@ +import "std/string" + +#[allow_unused] +fn usefn(val: i32, callback: fn(i32) ) { + callback(val * 2) +} + +type Point struct{ + x: i32 = 0, + y: i32 = 0 +} + +fn Point::Incr(&mut self) { + self.x++ + self.y++ +} + +fn Point::String(self) -> str { + let mut buff = string::FromStr("Point{ ") + buff.PushStr(self.x as str) + buff.PushStr(", ") + buff.PushStr(self.y as str) + buff.PushStr(" }") + + return buff.AsStr() +} + +fn main() { + println("Hello") + let name = "Fuad" + usefn(24, (v: i32) => { + println(v) + //println(name) + }) + + (v: str) => { + println(v) + }(name) + + let mut p = Point{ .x = 24, .y = 13 } + p.Incr() + println(p) + //somefn(name) +} \ No newline at end of file diff --git a/internal/analysis/layout/analysis/analyze.go b/internal/analysis/layout/analysis/analyze.go index 0a5d1e72..5c7b6c15 100644 --- a/internal/analysis/layout/analysis/analyze.go +++ b/internal/analysis/layout/analysis/analyze.go @@ -180,10 +180,10 @@ func (a *analyzer) layoutUnderlying(syntax any, typ typeinfo.Type) (int64, int64 } elemSize, elemAlign, known, _ := a.layoutUnderlying(nil, t.Inner) if !known { - return 0, maxInt64(1, elemAlign), false, nil + return 0, max(1, elemAlign), false, nil } stride := alignUp(elemSize, elemAlign) - return stride * t.Len, maxInt64(1, elemAlign), true, nil + return stride * t.Len, max(1, elemAlign), true, nil case *typeinfo.TupleType: return a.layoutSequential(t.Elems) case *typeinfo.StructType: @@ -246,7 +246,7 @@ func (a *analyzer) layoutSequential(elems []typeinfo.Type) (int64, int64, bool, } offset = alignUp(offset, align) offset += size - maxAlign = maxInt64(maxAlign, align) + maxAlign = max(maxAlign, align) known = known && elemKnown } return alignUp(offset, maxAlign), maxAlign, known, nil @@ -281,7 +281,7 @@ func (a *analyzer) layoutStruct(st *typeinfo.StructType) (int64, int64, bool, *l }) order = append(order, i) offset += size - maxAlign = maxInt64(maxAlign, align) + maxAlign = max(maxAlign, align) known = known && fieldKnown } total := alignUp(offset, maxAlign) @@ -305,8 +305,8 @@ func (a *analyzer) layoutTaggedUnionDetail(members []typeinfo.Type) (int64, int6 memberLayouts := make([]*layout.UnionMemberLayout, 0, len(members)) for _, member := range members { size, align, memberKnown, _ := a.layoutUnderlying(nil, member) - payloadSize = maxInt64(payloadSize, size) - payloadAlign = maxInt64(payloadAlign, align) + payloadSize = max(payloadSize, size) + payloadAlign = max(payloadAlign, align) known = known && memberKnown memberLayouts = append(memberLayouts, &layout.UnionMemberLayout{ Index: len(memberLayouts), @@ -315,7 +315,7 @@ func (a *analyzer) layoutTaggedUnionDetail(members []typeinfo.Type) (int64, int6 Align: align, }) } - align := maxInt64(tagAlign, payloadAlign) + align := max(tagAlign, payloadAlign) payloadOffset := alignUp(tagSize, payloadAlign) size := alignUp(payloadOffset+payloadSize, align) for _, member := range memberLayouts { @@ -370,13 +370,6 @@ func alignUp(value, align int64) int64 { return value + (align - rem) } -func maxInt64(a, b int64) int64 { - if a > b { - return a - } - return b -} - func unionStructLayout(u *layout.UnionLayout) *layout.StructLayout { if u == nil { return nil diff --git a/internal/ir/hir/generate_test.go b/internal/ir/hir/generate_test.go index a90bd379..fb563581 100644 --- a/internal/ir/hir/generate_test.go +++ b/internal/ir/hir/generate_test.go @@ -9,7 +9,7 @@ import ( "compiler/internal/core/context" "compiler/internal/core/diagnostics" "compiler/internal/core/phase" - compiler "compiler/internal/driver" + "compiler/internal/driver" "compiler/internal/ir/hir" ) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 6b67fa5e..7265b7c4 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -1524,10 +1524,14 @@ type completionCandidate struct { } type completionIndex struct { - keywords []completionItem - items []completionCandidate - mod *context.Module - modules map[string]*context.Module + keywords []completionItem + items []completionCandidate + structLiterals []struct { + location source.Location + items []completionItem + } + mod *context.Module + modules map[string]*context.Module } type hoverIndex struct { @@ -2403,6 +2407,7 @@ func collectCompletionIndex(mod *context.Module, info *typeinfo.ModuleInfo, modu } } } + collectStructLiteralCompletionCandidates(index, info) return index } @@ -2552,44 +2557,63 @@ func completionContextAt(sourceText string, pos source.Position) completionConte } func structLiteralCompletionItemsForContext(index *completionIndex, sourceText string, pos source.Position) ([]completionItem, bool) { - if index == nil || index.mod == nil || index.mod.Types == nil { + if index == nil || len(index.structLiterals) == 0 { return nil, false } - var best *typeinfo.NamedType + var best []completionItem bestSpan := int(^uint(0) >> 1) - for node, typ := range index.mod.Types.Nodes { - lit, ok := node.(*ast.CompositeLit) - if !ok || lit == nil || lit.Type == nil { + for _, candidate := range index.structLiterals { + if !locationContainsPosition(candidate.location, pos) { continue } - if !locationContainsPosition(lit.Location, pos) { + if _, ok := structLiteralFieldPrefix(sourceText, candidate.location, pos); !ok { continue } - if _, ok := structLiteralFieldPrefix(sourceText, lit, pos); !ok { + span := locationSpan(candidate.location) + if span < bestSpan { + best = candidate.items + bestSpan = span + } + } + if best == nil { + return nil, false + } + return best, true +} + +func collectStructLiteralCompletionCandidates(index *completionIndex, info *typeinfo.ModuleInfo) { + if index == nil || info == nil { + return + } + for node, typ := range info.Nodes { + lit, ok := node.(*ast.CompositeLit) + if !ok || lit == nil || lit.Type == nil { continue } named := underlyingNamedType(typ) if named == nil { continue } - span := locationSpan(lit.Location) - if span < bestSpan { - best = named - bestSpan = span + items := structFieldCompletionItemsForNamed(index.mod, index.modules, named) + if len(items) == 0 { + continue } + index.structLiterals = append(index.structLiterals, struct { + location source.Location + items []completionItem + }{ + location: lit.Location, + items: items, + }) } - if best == nil { - return nil, false - } - return structFieldCompletionItemsForNamed(index.mod, index.modules, best), true } -func structLiteralFieldPrefix(sourceText string, lit *ast.CompositeLit, pos source.Position) (string, bool) { +func structLiteralFieldPrefix(sourceText string, loc source.Location, pos source.Position) (string, bool) { offset := sourceOffsetAtPosition(sourceText, pos) - if offset < 0 || offset > len(sourceText) || lit == nil || lit.Location.Start == nil { + if offset < 0 || offset > len(sourceText) || loc.Start == nil { return "", false } - start := lit.Location.Start.Index + start := loc.Start.Index if start < 0 || start > offset || start >= len(sourceText) { return "", false } From 6e474adf5d798156e75990bcd1bcf84ad6a7ac72 Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Tue, 21 Apr 2026 21:56:34 +0600 Subject: [PATCH 4/6] Add overlay parsing and hover cache Introduce Compiler.ParsePathWithOverlay and pipeline overlay methods (ParseEntryOverlay, ParseEntryOverlayForIDE) with shared finishEntryParse and scheduleParseOverlayFile to parse temporary overlay contents without changing module identity. LSP now writes hover overlays into FERRET_CACHE (or user cache), prunes stale overlays, and uses ParsePathWithOverlay for in-memory buffers. Add ferretCacheDir, hoverOverlayDir, functionPointer helpers and tests for overlay behavior. Rules check: no pass-through wrappers added; reused pipeline logic; added helpers limited to cache/overlay management. --- internal/driver/compiler.go | 50 +++++++++++++++++++ internal/driver/compiler_test.go | 26 ++++++++++ internal/lsp/server.go | 82 ++++++++++++++++++++++++-------- internal/lsp/server_test.go | 14 +++++- internal/pipeline/pipeline.go | 68 ++++++++++++++++++++------ 5 files changed, 206 insertions(+), 34 deletions(-) diff --git a/internal/driver/compiler.go b/internal/driver/compiler.go index 78aea9d0..3ce5839f 100644 --- a/internal/driver/compiler.go +++ b/internal/driver/compiler.go @@ -139,6 +139,56 @@ func ParsePathForIDE(path string) Result { return parsePath(path, parseModeIDE) } +// ParsePathWithOverlay parses originalPath using overlayPath content while +// keeping project roots, import paths, and diagnostic paths tied to originalPath. +func ParsePathWithOverlay(originalPath, overlayPath string, ide bool) Result { + mode := parseModeFull + if ide { + mode = parseModeIDE + } + absPath, err := filepath.Abs(originalPath) + diag := diagnostics.NewDiagnosticBag(absPath) + if err != nil { + diag.Add(diagnostics.NewError(err.Error())) + return Result{Diagnostics: diag} + } + absOverlay, err := filepath.Abs(overlayPath) + if err != nil { + diag.Add(diagnostics.NewError(err.Error())) + return Result{Diagnostics: diag} + } + if ext := strings.ToLower(filepath.Ext(absPath)); ext != FerretSourceExt { + diag.Add(diagnostics.NewError("unsupported source file extension")) + return Result{Diagnostics: diag} + } + ws, err := project.Load(absPath, FerretSourceExt) + if err != nil { + diag.Add(diagnostics.NewError(err.Error())) + return Result{Diagnostics: diag} + } + c := NewWithConfig(ws.Context, diag) + var entry *context.Module + switch mode { + case parseModeIDE: + entry, err = c.pipeline.ParseEntryOverlayForIDE(absPath, absOverlay) + default: + entry, err = c.pipeline.ParseEntryOverlay(absPath, absOverlay) + } + if err != nil { + c.ctx.Diagnostics.Add(diagnostics.NewError(err.Error())) + } + result := Result{ + Entry: entry, + Modules: c.ctx.NonPreludeModules(), + Diagnostics: c.ctx.Diagnostics, + CompilerState: c.ctx, + } + if entry != nil { + result.Module = entry.AST + } + return result +} + func (c *Compiler) ParseEntry(entryFile string) Result { entry, err := c.pipeline.ParseEntry(entryFile) if err != nil { diff --git a/internal/driver/compiler_test.go b/internal/driver/compiler_test.go index 3246930f..396b6bbf 100644 --- a/internal/driver/compiler_test.go +++ b/internal/driver/compiler_test.go @@ -523,6 +523,32 @@ func TestParsePathForIDEReportsUnusedLocalDiagnostics(t *testing.T) { } } +func TestParsePathWithOverlayForIDEAcceptsNonSourceExtension(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "main.fer") + overlayPath := filepath.Join(root, "overlay.tmp") + mustWrite(t, sourcePath, `fn main() -> void { +} +`) + mustWrite(t, overlayPath, `fn main() -> void { + let overlay_value = 1 +} +`) + + rejected := ParsePathForIDE(overlayPath) + if !rejected.Diagnostics.HasErrors() { + t.Fatalf("expected normal IDE parse to reject non-source extension") + } + + result := ParsePathWithOverlay(sourcePath, overlayPath, true) + if result.Diagnostics.HasErrors() { + t.Fatalf("unexpected overlay diagnostics: %#v", result.Diagnostics.Diagnostics()) + } + if result.Entry == nil || result.Entry.FilePath != filepath.Clean(sourcePath) { + t.Fatalf("expected source entry %q, got %#v", filepath.Clean(sourcePath), result.Entry) + } +} + func TestParsePathRejectsNonCanonicalRecursiveGenericSelfUseBeforeLowering(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "main.fer"), ` diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 7265b7c4..67485d89 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -10,6 +10,7 @@ import ( "net/url" "os" "path/filepath" + "reflect" "regexp" "sort" "strconv" @@ -62,6 +63,13 @@ const ( completionKindConstant = 21 completionKindStruct = 22 completionKindTypeParameter = 25 + + ferretCacheEnv = "FERRET_CACHE" + ferretCacheDirName = "ferret-build" + hoverOverlayDirName = "lsp-overlays" + hoverOverlayGlob = ".ferretls-hover-*" + hoverOverlayTempPattern = hoverOverlayGlob + ".tmp" + hoverOverlayStaleThreshold = 10 * time.Minute ) var ( @@ -93,14 +101,8 @@ var ( parseSource = func(path string, toks []tokens.Token, diag *diagnostics.DiagnosticBag) { _ = parser.Parse(path, toks, diag) } - parseProject = func(path string) compiler.Result { - // LSP needs resolver + typechecker data. Backend passes are too expensive - // during interactive editing (especially generics-heavy code). - return compiler.ParsePathForIDE(path) - } - parseSemanticProject = func(path string) compiler.Result { - return compiler.ParsePath(path) - } + parseProject = compiler.ParsePathForIDE + parseSemanticProject = compiler.ParsePath ) type rpcRequest struct { @@ -771,7 +773,7 @@ func (s *Server) handleRename(req rpcRequest) { if hasOverlay { sourceText = doc.Text } - result, parsedPath, cleanup := parseForProject(path, sourceText, hasOverlay, parseProject) + result, parsedPath, cleanup := parseForProject(path, sourceText, hasOverlay, parseProject, compiler.ParsePathForIDE, true) defer cleanup() targetModule := findModuleByPath(result, parsedPath) @@ -1746,14 +1748,14 @@ func indexModulesByKey(result compiler.Result) map[string]*context.Module { } func parseForHover(path, text string, hasText bool) (compiler.Result, string, func()) { - return parseForProject(path, text, hasText, parseProject) + return parseForProject(path, text, hasText, parseProject, compiler.ParsePathForIDE, true) } func parseForSemanticDiagnostics(path, text string, hasText bool) (compiler.Result, string, func()) { - return parseForProject(path, text, hasText, parseSemanticProject) + return parseForProject(path, text, hasText, parseSemanticProject, compiler.ParsePath, false) } -func parseForProject(path, text string, hasText bool, parse func(path string) compiler.Result) (compiler.Result, string, func()) { +func parseForProject(path, text string, hasText bool, parse, defaultParse func(path string) compiler.Result, ide bool) (compiler.Result, string, func()) { if !hasText { return parse(path), path, func() {} } @@ -1762,14 +1764,26 @@ func parseForProject(path, text string, hasText bool, parse func(path string) co return parse(path), path, func() {} } cleanup := func() { _ = os.Remove(tempPath) } - return parse(tempPath), tempPath, cleanup + if functionPointer(parse) != functionPointer(defaultParse) { + return parse(path), path, cleanup + } + return compiler.ParsePathWithOverlay(path, tempPath, ide), path, cleanup } -func writeHoverOverlay(originalPath, text string) (string, error) { - dir := filepath.Dir(originalPath) - const staleOverlayTTL = 10 * time.Minute - cutoff := time.Now().Add(-staleOverlayTTL) - if stale, err := filepath.Glob(filepath.Join(dir, ".ferretls-hover-*.fer")); err == nil { +func functionPointer(fn any) uintptr { + if fn == nil { + return 0 + } + return reflect.ValueOf(fn).Pointer() +} + +func writeHoverOverlay(_ string, text string) (string, error) { + dir, err := hoverOverlayDir() + if err != nil { + return "", err + } + cutoff := time.Now().Add(-hoverOverlayStaleThreshold) + if stale, err := filepath.Glob(filepath.Join(dir, hoverOverlayGlob)); err == nil { for _, candidate := range stale { info, statErr := os.Stat(candidate) if statErr != nil || info.IsDir() { @@ -1781,7 +1795,7 @@ func writeHoverOverlay(originalPath, text string) (string, error) { _ = os.Remove(candidate) } } - file, err := os.CreateTemp(dir, ".ferretls-hover-*.fer") + file, err := os.CreateTemp(dir, hoverOverlayTempPattern) if err != nil { return "", err } @@ -1798,6 +1812,36 @@ func writeHoverOverlay(originalPath, text string) (string, error) { return tempPath, nil } +func hoverOverlayDir() (string, error) { + root, err := ferretCacheDir() + if err != nil { + return "", err + } + dir := filepath.Join(root, hoverOverlayDirName) + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + return dir, nil +} + +func ferretCacheDir() (string, error) { + root := os.Getenv(ferretCacheEnv) + if root == "" { + cache, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("%s is not defined and %w", ferretCacheEnv, err) + } + root = filepath.Join(cache, ferretCacheDirName) + } + if root == "off" { + return "", fmt.Errorf("%s=off", ferretCacheEnv) + } + if !filepath.IsAbs(root) { + return "", fmt.Errorf("%s is not an absolute path", ferretCacheEnv) + } + return root, nil +} + func findModuleByPath(result compiler.Result, parsedPath string) *context.Module { absTarget, err := filepath.Abs(parsedPath) if err != nil { diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index c4bc69a4..33436a2d 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -450,8 +450,14 @@ func TestFileURIPathPrefixesWindowsDrive(t *testing.T) { func TestWriteHoverOverlayPrunesStaleOverlays(t *testing.T) { dir := t.TempDir() + cacheDir := filepath.Join(dir, "ferret-cache") + t.Setenv(ferretCacheEnv, cacheDir) sourcePath := filepath.Join(dir, "main.fer") - stalePath := filepath.Join(dir, ".ferretls-hover-stale.fer") + staleName := strings.Replace(hoverOverlayGlob, "*", "stale"+compiler.FerretSourceExt, 1) + stalePath := filepath.Join(cacheDir, hoverOverlayDirName, staleName) + if err := os.MkdirAll(filepath.Dir(stalePath), 0o700); err != nil { + t.Fatalf("create overlay cache dir: %v", err) + } if err := os.WriteFile(stalePath, []byte("fn broken("), 0o644); err != nil { t.Fatalf("write stale overlay: %v", err) } @@ -465,6 +471,12 @@ func TestWriteHoverOverlayPrunesStaleOverlays(t *testing.T) { t.Fatalf("write overlay: %v", err) } defer func() { _ = os.Remove(tempPath) }() + if filepath.Ext(tempPath) == compiler.FerretSourceExt { + t.Fatalf("expected non-source overlay extension, got %q", tempPath) + } + if !strings.HasPrefix(filepath.Clean(tempPath), filepath.Clean(cacheDir)) { + t.Fatalf("expected overlay in cache dir %q, got %q", cacheDir, tempPath) + } if _, err := os.Stat(stalePath); !os.IsNotExist(err) { t.Fatalf("expected stale overlay to be removed, stat err=%v", err) diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 31638723..78cb0e21 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -57,19 +57,17 @@ func (p *Pipeline) ParseEntryForIDE(entryFile string) (*context.Module, error) { return nil, err } p.scheduleParseFile(resolved, nil) - p.wg.Wait() - if mod, ok := p.ctx.GetModule(resolved.Key); ok && mod != nil { - mod.IsEntry = true - if synthesizeTestHarness(mod.AST, p.ctx.Config.TestMode, p.ctx.Config.TestName) { - collector.CollectModule(p.ctx, mod) - } - } - if err := p.runIDEPasses(); err != nil { + return p.finishEntryParse(resolved, p.runIDEPasses, p.runIDEFinalPasses) +} + +// ParseEntryOverlayForIDE parses entryFile with content read from overlayFile. +func (p *Pipeline) ParseEntryOverlayForIDE(entryFile, overlayFile string) (*context.Module, error) { + resolved, err := p.ctx.ResolveLocalModule(entryFile) + if err != nil { return nil, err } - p.runIDEFinalPasses() - mod, _ := p.ctx.GetModule(resolved.Key) - return mod, nil + p.scheduleParseOverlayFile(resolved, overlayFile, nil) + return p.finishEntryParse(resolved, p.runIDEPasses, p.runIDEFinalPasses) } // ParseWorkspaceForIDE parses all source files in the workspace root then runs @@ -101,6 +99,20 @@ func (p *Pipeline) ParseEntry(entryFile string) (*context.Module, error) { return nil, err } p.scheduleParseFile(resolved, nil) + return p.finishEntryParse(resolved, p.runAllSemanticPasses, p.finalizeFinalPasses) +} + +// ParseEntryOverlay parses entryFile with content read from overlayFile. +func (p *Pipeline) ParseEntryOverlay(entryFile, overlayFile string) (*context.Module, error) { + resolved, err := p.ctx.ResolveLocalModule(entryFile) + if err != nil { + return nil, err + } + p.scheduleParseOverlayFile(resolved, overlayFile, nil) + return p.finishEntryParse(resolved, p.runAllSemanticPasses, p.finalizeFinalPasses) +} + +func (p *Pipeline) finishEntryParse(resolved context.ResolvedImport, runPasses func() error, finalize func()) (*context.Module, error) { p.wg.Wait() if mod, ok := p.ctx.GetModule(resolved.Key); ok && mod != nil { mod.IsEntry = true @@ -108,10 +120,12 @@ func (p *Pipeline) ParseEntry(entryFile string) (*context.Module, error) { collector.CollectModule(p.ctx, mod) } } - if err := p.runAllSemanticPasses(); err != nil { + if err := runPasses(); err != nil { return nil, err } - p.finalizeFinalPasses() + if finalize != nil { + finalize() + } mod, _ := p.ctx.GetModule(resolved.Key) return mod, nil } @@ -148,6 +162,15 @@ func (p *Pipeline) scheduleParseFile(resolved context.ResolvedImport, loc *sourc }) } +func (p *Pipeline) scheduleParseOverlayFile(resolved context.ResolvedImport, overlayFile string, loc *source.Location) { + if _, loaded := p.seen.LoadOrStore(resolved.Key, struct{}{}); loaded { + return + } + p.wg.Go(func() { + p.parseOverlayFile(resolved, overlayFile, loc) + }) +} + // parseFile lexes, parses, and symbol-collects one module, then schedules its // imports as additional goroutines. Runs concurrently for every reachable module. func (p *Pipeline) parseFile(resolved context.ResolvedImport, loc *source.Location) { @@ -161,8 +184,25 @@ func (p *Pipeline) parseFile(resolved context.ResolvedImport, loc *source.Locati ) return } + p.parseModuleContent(mod, string(content)) +} + +func (p *Pipeline) parseOverlayFile(resolved context.ResolvedImport, overlayFile string, loc *source.Location) { + mod := p.ctx.UpsertModule(resolved) + content, err := os.ReadFile(overlayFile) + if err != nil { + p.ctx.Diagnostics.Add( + diagnostics.NewError(fmt.Sprintf("cannot read module %s", mod.ImportPath)). + WithCode(diagnostics.ErrModuleNotFound). + WithPrimaryLabel(loc, err.Error()), + ) + return + } + p.parseModuleContent(mod, string(content)) +} - changed := p.ctx.StoreModuleContent(mod, string(content)) +func (p *Pipeline) parseModuleContent(mod *context.Module, content string) { + changed := p.ctx.StoreModuleContent(mod, content) p.ctx.Diagnostics.AddSourceContent(mod.FilePath, mod.Content) if mod.Phase >= phase.PhaseLayoutComputed && !changed { From ab4b1a71f98430340ffcf6dcc103f8cf974658ea Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Wed, 22 Apr 2026 01:54:07 +0600 Subject: [PATCH 5/6] Address cache overlay review comments --- dry_test.fer | 44 -------------------------------- internal/ir/hir/generate_test.go | 2 +- internal/lsp/server.go | 31 ++++++++++++---------- internal/lsp/server_test.go | 18 +++++++++++++ 4 files changed, 37 insertions(+), 58 deletions(-) delete mode 100644 dry_test.fer diff --git a/dry_test.fer b/dry_test.fer deleted file mode 100644 index cc192e52..00000000 --- a/dry_test.fer +++ /dev/null @@ -1,44 +0,0 @@ -import "std/string" - -#[allow_unused] -fn usefn(val: i32, callback: fn(i32) ) { - callback(val * 2) -} - -type Point struct{ - x: i32 = 0, - y: i32 = 0 -} - -fn Point::Incr(&mut self) { - self.x++ - self.y++ -} - -fn Point::String(self) -> str { - let mut buff = string::FromStr("Point{ ") - buff.PushStr(self.x as str) - buff.PushStr(", ") - buff.PushStr(self.y as str) - buff.PushStr(" }") - - return buff.AsStr() -} - -fn main() { - println("Hello") - let name = "Fuad" - usefn(24, (v: i32) => { - println(v) - //println(name) - }) - - (v: str) => { - println(v) - }(name) - - let mut p = Point{ .x = 24, .y = 13 } - p.Incr() - println(p) - //somefn(name) -} \ No newline at end of file diff --git a/internal/ir/hir/generate_test.go b/internal/ir/hir/generate_test.go index fb563581..a90bd379 100644 --- a/internal/ir/hir/generate_test.go +++ b/internal/ir/hir/generate_test.go @@ -9,7 +9,7 @@ import ( "compiler/internal/core/context" "compiler/internal/core/diagnostics" "compiler/internal/core/phase" - "compiler/internal/driver" + compiler "compiler/internal/driver" "compiler/internal/ir/hir" ) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 67485d89..b3b759f1 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -1780,20 +1780,10 @@ func functionPointer(fn any) uintptr { func writeHoverOverlay(_ string, text string) (string, error) { dir, err := hoverOverlayDir() if err != nil { - return "", err + dir = "" } - cutoff := time.Now().Add(-hoverOverlayStaleThreshold) - if stale, err := filepath.Glob(filepath.Join(dir, hoverOverlayGlob)); err == nil { - for _, candidate := range stale { - info, statErr := os.Stat(candidate) - if statErr != nil || info.IsDir() { - continue - } - if info.ModTime().After(cutoff) { - continue - } - _ = os.Remove(candidate) - } + if dir != "" { + pruneStaleHoverOverlays(dir) } file, err := os.CreateTemp(dir, hoverOverlayTempPattern) if err != nil { @@ -1812,6 +1802,21 @@ func writeHoverOverlay(_ string, text string) (string, error) { return tempPath, nil } +func pruneStaleHoverOverlays(dir string) { + cutoff := time.Now().Add(-hoverOverlayStaleThreshold) + stale, err := filepath.Glob(filepath.Join(dir, hoverOverlayGlob)) + if err != nil { + return + } + for _, candidate := range stale { + info, statErr := os.Stat(candidate) + if statErr != nil || info.IsDir() || info.ModTime().After(cutoff) { + continue + } + _ = os.Remove(candidate) + } +} + func hoverOverlayDir() (string, error) { root, err := ferretCacheDir() if err != nil { diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 33436a2d..a067798e 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -483,6 +483,24 @@ func TestWriteHoverOverlayPrunesStaleOverlays(t *testing.T) { } } +func TestWriteHoverOverlayFallsBackWhenCacheDisabled(t *testing.T) { + t.Setenv(ferretCacheEnv, "off") + + tempPath, err := writeHoverOverlay("main.fer", "fn main() {}") + if err != nil { + t.Fatalf("write overlay: %v", err) + } + defer func() { _ = os.Remove(tempPath) }() + + got, err := os.ReadFile(tempPath) + if err != nil { + t.Fatalf("read overlay: %v", err) + } + if string(got) != "fn main() {}" { + t.Fatalf("expected fallback overlay text, got %q", string(got)) + } +} + func TestHoverReturnsTypeFromSavedFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "main.fer") From 1120d1d463c2b15319f8d6edbef7016c40af24d8 Mon Sep 17 00:00:00 2001 From: Fuad Hasan Date: Wed, 22 Apr 2026 18:35:57 +0600 Subject: [PATCH 6/6] Address cache overlay follow-up review --- internal/driver/compiler_test.go | 20 ++++++++++++++++++++ internal/lsp/server.go | 2 +- internal/lsp/server_test.go | 20 +++----------------- internal/pipeline/pipeline.go | 2 +- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/internal/driver/compiler_test.go b/internal/driver/compiler_test.go index 396b6bbf..aed0c0d6 100644 --- a/internal/driver/compiler_test.go +++ b/internal/driver/compiler_test.go @@ -549,6 +549,26 @@ func TestParsePathWithOverlayForIDEAcceptsNonSourceExtension(t *testing.T) { } } +func TestParsePathWithOverlayReportsOverlayReadPath(t *testing.T) { + root := t.TempDir() + sourcePath := filepath.Join(root, "main.fer") + overlayPath := filepath.Join(root, "missing-overlay.tmp") + mustWrite(t, sourcePath, `fn main() -> void { +} +`) + + result := ParsePathWithOverlay(sourcePath, overlayPath, true) + if !result.Diagnostics.HasErrors() { + t.Fatal("expected missing overlay diagnostic") + } + for _, diag := range result.Diagnostics.Diagnostics() { + if diag != nil && strings.Contains(diag.Message, "cannot read overlay file "+overlayPath) { + return + } + } + t.Fatalf("expected overlay path diagnostic, got %#v", result.Diagnostics.Diagnostics()) +} + func TestParsePathRejectsNonCanonicalRecursiveGenericSelfUseBeforeLowering(t *testing.T) { root := t.TempDir() mustWrite(t, filepath.Join(root, "main.fer"), ` diff --git a/internal/lsp/server.go b/internal/lsp/server.go index b3b759f1..76d34b1b 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -2717,7 +2717,7 @@ func structLiteralFieldPrefix(sourceText string, loc source.Location, pos source } item := strings.TrimSpace(body[itemStart:]) if item == "" { - return "", true + return "", false } if !strings.HasPrefix(item, ".") { return "", false diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index a067798e..1e066c78 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -3710,7 +3710,7 @@ func TestCompletionSuggestsFerretAttributesInAttributeContext(t *testing.T) { } } -func TestCompletionSuggestsStructLiteralFields(t *testing.T) { +func TestCompletionDoesNotSuggestStructLiteralFieldsBeforeDot(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "main.fer") src := "type User struct {\n name: str\n age: i32\n}\n\nfn (self: User) label() -> str {\n return self.name\n}\n\nfn main() {\n let user = User{\n \n }\n user\n}\n" @@ -3742,8 +3742,6 @@ func TestCompletionSuggestsStructLiteralFields(t *testing.T) { items := decodeCompletionResult(t, out.String()) foundName := false foundAge := false - foundIf := false - foundMethod := false for _, item := range items { if item.Label == "name" && item.Kind == completionKindField { foundName = true @@ -3751,21 +3749,9 @@ func TestCompletionSuggestsStructLiteralFields(t *testing.T) { if item.Label == "age" && item.Kind == completionKindField { foundAge = true } - if item.Label == "if" { - foundIf = true - } - if item.Label == "label" { - foundMethod = true - } - } - if !foundName || !foundAge { - t.Fatalf("expected struct field completions, got %#v", items) - } - if foundIf { - t.Fatalf("did not expect keyword completion in struct literal field context, got %#v", items) } - if foundMethod { - t.Fatalf("did not expect method completion in struct literal field context, got %#v", items) + if foundName || foundAge { + t.Fatalf("did not expect struct field completions before dot, got %#v", items) } } diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 78cb0e21..35c840be 100644 --- a/internal/pipeline/pipeline.go +++ b/internal/pipeline/pipeline.go @@ -192,7 +192,7 @@ func (p *Pipeline) parseOverlayFile(resolved context.ResolvedImport, overlayFile content, err := os.ReadFile(overlayFile) if err != nil { p.ctx.Diagnostics.Add( - diagnostics.NewError(fmt.Sprintf("cannot read module %s", mod.ImportPath)). + diagnostics.NewError(fmt.Sprintf("cannot read overlay file %s for module %s", overlayFile, mod.ImportPath)). WithCode(diagnostics.ErrModuleNotFound). WithPrimaryLabel(loc, err.Error()), )