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/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..aed0c0d6 100644 --- a/internal/driver/compiler_test.go +++ b/internal/driver/compiler_test.go @@ -523,6 +523,52 @@ 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 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/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..76d34b1b 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 ( @@ -77,7 +85,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 }() @@ -88,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 { @@ -766,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) @@ -1519,10 +1526,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 { @@ -1737,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() {} } @@ -1753,26 +1764,28 @@ 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 { - for _, candidate := range stale { - info, statErr := os.Stat(candidate) - if statErr != nil || info.IsDir() { - continue - } - if info.ModTime().After(cutoff) { - continue - } - _ = os.Remove(candidate) - } +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 { + dir = "" + } + if dir != "" { + pruneStaleHoverOverlays(dir) } - file, err := os.CreateTemp(dir, ".ferretls-hover-*.fer") + file, err := os.CreateTemp(dir, hoverOverlayTempPattern) if err != nil { return "", err } @@ -1789,6 +1802,51 @@ func writeHoverOverlay(originalPath, 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 { + 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 { @@ -1904,9 +1962,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 @@ -2257,7 +2357,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)...) @@ -2354,6 +2456,7 @@ func collectCompletionIndex(mod *context.Module, info *typeinfo.ModuleInfo, modu } } } + collectStructLiteralCompletionCandidates(index, info) return index } @@ -2502,6 +2605,130 @@ func completionContextAt(sourceText string, pos source.Position) completionConte return completionContext{} } +func structLiteralCompletionItemsForContext(index *completionIndex, sourceText string, pos source.Position) ([]completionItem, bool) { + if index == nil || len(index.structLiterals) == 0 { + return nil, false + } + var best []completionItem + bestSpan := int(^uint(0) >> 1) + for _, candidate := range index.structLiterals { + if !locationContainsPosition(candidate.location, pos) { + continue + } + if _, ok := structLiteralFieldPrefix(sourceText, candidate.location, pos); !ok { + continue + } + 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 + } + 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, + }) + } +} + +func structLiteralFieldPrefix(sourceText string, loc source.Location, pos source.Position) (string, bool) { + offset := sourceOffsetAtPosition(sourceText, pos) + if offset < 0 || offset > len(sourceText) || loc.Start == nil { + return "", false + } + start := loc.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 "", false + } + 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 @@ -2652,32 +2879,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 { @@ -2700,6 +2902,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 == "" { @@ -3075,8 +3332,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..1e066c78 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,12 +471,36 @@ 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) } } +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") @@ -614,6 +644,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 +3710,102 @@ func TestCompletionSuggestsFerretAttributesInAttributeContext(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" + 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 + for _, item := range items { + if item.Label == "name" && item.Kind == completionKindField { + foundName = true + } + if item.Label == "age" && item.Kind == completionKindField { + foundAge = true + } + } + if foundName || foundAge { + t.Fatalf("did not expect struct field completions before dot, got %#v", items) + } +} + +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") diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go index 31638723..35c840be 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 overlay file %s for module %s", overlayFile, 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 {