From e2f5f990dcf8faa785bdef6c084d832186ae2e3c Mon Sep 17 00:00:00 2001 From: jupblb Date: Thu, 18 Jun 2026 14:10:15 +0200 Subject: [PATCH 1/3] snapshot: support enclosing_symbol, multiline occurrences, synthetic_definition Render enclosing_symbol in definition info blocks, multiline occurrences on their start line with a lineDelta:endChar suffix, and synthetic_definition occurrences (derived from is_definition relationships) using '_' markers. Also render the is_definition relationship flag and clamp empty ranges to a single marker. --- bindings/go/scip/testutil/format.go | 201 ++++++++++++------ bindings/go/scip/testutil/format_test.go | 84 ++++++++ .../output/relationships/defined_by.repro | 3 + 3 files changed, 224 insertions(+), 64 deletions(-) create mode 100644 bindings/go/scip/testutil/format_test.go diff --git a/bindings/go/scip/testutil/format.go b/bindings/go/scip/testutil/format.go index 942f55a2..5b6d5e0c 100644 --- a/bindings/go/scip/testutil/format.go +++ b/bindings/go/scip/testutil/format.go @@ -98,6 +98,134 @@ func FormatSnapshot( enclosingRanges := enclosingRanges(document.Occurrences) enclosingByStartLine := enclosingRangesByStartLine(enclosingRanges) enclosingByEndLine := enclosingRangesByEndLine(enclosingRanges) + + // syntheticDefinitions maps a "parent" symbol to the SymbolInformation + // entries that declare a definition relationship on it (i.e. symbols whose + // definition is synthesized at the parent's definition site). These are + // rendered as additional `synthetic_definition` occurrences underneath the + // parent definition occurrence. + syntheticDefinitions := map[string][]*scip.SymbolInformation{} + for _, info := range document.Symbols { + for _, rel := range info.Relationships { + if rel.IsDefinition { + syntheticDefinitions[rel.Symbol] = append(syntheticDefinitions[rel.Symbol], info) + } + } + } + for _, infos := range syntheticDefinitions { + sort.SliceStable(infos, func(i, j int) bool { + return infos[i].Symbol < infos[j].Symbol + }) + } + + // formatOccurrence renders a single occurrence and its associated metadata. + // When synthetic is non-nil, the occurrence is rendered as a + // `synthetic_definition` for synthetic.Symbol, reusing occ's source range. + formatOccurrence := func(occ *scip.Occurrence, synthetic *scip.SymbolInformation, renderedLine string) { + pos, _ := occ.SourceRange() + isDefinition := scip.SymbolRole_Definition.Matches(occ) + + marker := byte('^') + if synthetic != nil { + marker = '_' + } + + b.WriteString(commentSyntax) + for indent := int32(0); indent < pos.Start.Character; indent++ { + b.WriteRune(' ') + } + + markerCount := pos.End.Character - pos.Start.Character + if !pos.IsSingleLine() { + // Multiline occurrences are anchored to their start line and the + // markers extend to the end of that line. The end position is + // reported via the `:` suffix below. + markerCount = int32(len(renderedLine)) - pos.Start.Character + } + if markerCount < 1 { + // SCIP allows empty ranges; always render at least one marker. + markerCount = 1 + } + for c := int32(0); c < markerCount; c++ { + b.WriteByte(marker) + } + + b.WriteRune(' ') + role := "reference" + switch { + case synthetic != nil: + role = "synthetic_definition" + case isDefinition: + role = "definition" + case scip.SymbolRole_ForwardDefinition.Matches(occ): + role = "forward_definition" + } + b.WriteString(role) + b.WriteRune(' ') + symbol := occ.Symbol + if synthetic != nil { + symbol = synthetic.Symbol + } + b.WriteString(formatSymbol(symbol)) + if !pos.IsSingleLine() { + fmt.Fprintf(&b, " %d:%d", pos.End.Line-pos.Start.Line, pos.End.Character) + } + + prefix := "\n" + commentSyntax + strings.Repeat(" ", int(pos.Start.Character+markerCount)+1) + + // Override documentation and diagnostics are occurrence-level, so they + // are only rendered for the real occurrence, not synthetic copies. + if synthetic == nil && len(occ.OverrideDocumentation) > 0 { + writeDocumentation(&b, occ.OverrideDocumentation[0], prefix, true) + } + + info := synthetic + if info == nil { + info = symtab[occ.Symbol] + } + if info != nil && isDefinition { + if info.Kind != scip.SymbolInformation_UnspecifiedKind { + b.WriteString(prefix) + b.WriteString("kind ") + b.WriteString(info.Kind.String()) + } + + if info.DisplayName != "" { + b.WriteString(prefix) + b.WriteString("display_name ") + b.WriteString(info.DisplayName) + } + + if info.SignatureDocumentation != nil && info.SignatureDocumentation.Text != "" { + b.WriteString(prefix) + b.WriteString("signature_documentation") + writeMultiline(&b, prefix, info.SignatureDocumentation.Text) + } + + if info.EnclosingSymbol != "" { + b.WriteString(prefix) + b.WriteString("enclosing_symbol ") + b.WriteString(formatSymbol(info.EnclosingSymbol)) + } + + for _, documentation := range info.Documentation { + // At least get the first line of documentation if there is leading whitespace + documentation = strings.TrimSpace(documentation) + writeDocumentation(&b, documentation, prefix, false) + } + + writeRelationships(&b, info.Relationships, prefix, formatSymbol) + } + + if synthetic == nil { + for _, diagnostic := range occ.Diagnostics { + writeDiagnostic(&b, prefix, diagnostic) + } + } + + b.WriteString("\n") + } + i := 0 for lineNumber, line := range strings.Split(string(data), "\n") { for _, er := range enclosingByStartLine[int32(lineNumber)] { @@ -111,8 +239,9 @@ func FormatSnapshot( } line = strings.TrimSuffix(line, "\r") + renderedLine := strings.ReplaceAll(line, "\t", " ") b.WriteString(strings.Repeat(" ", len(commentSyntax))) - b.WriteString(strings.ReplaceAll(line, "\t", " ")) + b.WriteString(renderedLine) b.WriteString("\n") for i < len(document.Occurrences) { occ := document.Occurrences[i] @@ -120,71 +249,12 @@ func FormatSnapshot( if pos.Start.Line != int32(lineNumber) { break } - if !pos.IsSingleLine() { - i++ - continue - } - b.WriteString(commentSyntax) - for indent := int32(0); indent < pos.Start.Character; indent++ { - b.WriteRune(' ') - } - length := pos.End.Character - pos.Start.Character - for caret := int32(0); caret < length; caret++ { - b.WriteRune('^') - } - b.WriteRune(' ') - role := "reference" - isDefinition := scip.SymbolRole_Definition.Matches(occ) - if isDefinition { - role = "definition" - } else if scip.SymbolRole_ForwardDefinition.Matches(occ) { - role = "forward_definition" - } - b.WriteString(role) - b.WriteRune(' ') - b.WriteString(formatSymbol(occ.Symbol)) - - prefix := "\n" + commentSyntax + strings.Repeat(" ", int(pos.End.Character)+1) - - hasOverrideDocumentation := len(occ.OverrideDocumentation) > 0 - if hasOverrideDocumentation { - documentation := occ.OverrideDocumentation[0] - writeDocumentation(&b, documentation, prefix, true) - } - - if info, ok := symtab[occ.Symbol]; ok && isDefinition { - if info.Kind != scip.SymbolInformation_UnspecifiedKind { - b.WriteString(prefix) - b.WriteString("kind ") - b.WriteString(info.Kind.String()) - } - - if info.DisplayName != "" { - b.WriteString(prefix) - b.WriteString("display_name ") - b.WriteString(info.DisplayName) - } - - if info.SignatureDocumentation != nil && info.SignatureDocumentation.Text != "" { - b.WriteString(prefix) - b.WriteString("signature_documentation") - writeMultiline(&b, prefix, info.SignatureDocumentation.Text) - } - - for _, documentation := range info.Documentation { - // At least get the first line of documentation if there is leading whitespace - documentation = strings.TrimSpace(documentation) - writeDocumentation(&b, documentation, prefix, false) + formatOccurrence(occ, nil, renderedLine) + if scip.SymbolRole_Definition.Matches(occ) { + for _, synthetic := range syntheticDefinitions[occ.Symbol] { + formatOccurrence(occ, synthetic, renderedLine) } - - writeRelationships(&b, info.Relationships, prefix, formatSymbol) - } - - for _, diagnostic := range occ.Diagnostics { - writeDiagnostic(&b, prefix, diagnostic) } - - b.WriteString("\n") i++ } for _, er := range enclosingByEndLine[int32(lineNumber)] { @@ -257,6 +327,9 @@ func writeRelationships( if relationship.IsTypeDefinition { b.WriteString(" type_definition") } + if relationship.IsDefinition { + b.WriteString(" definition") + } } } diff --git a/bindings/go/scip/testutil/format_test.go b/bindings/go/scip/testutil/format_test.go new file mode 100644 index 00000000..b2181875 --- /dev/null +++ b/bindings/go/scip/testutil/format_test.go @@ -0,0 +1,84 @@ +package testutil + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/scip-code/scip/bindings/go/scip" +) + +func rangeAt(startLine, startChar, endLine, endChar int32) scip.Range { + return scip.Range{ + Start: scip.Position{Line: startLine, Character: startChar}, + End: scip.Position{Line: endLine, Character: endChar}, + } +} + +// TestFormatSnapshotFeatures exercises enclosing_symbol, multiline occurrences +// and synthetic_definition rendering, plus the zero-width range edge case. +func TestFormatSnapshotFeatures(t *testing.T) { + source := "package foo\nfunc bar() {\n return\n}\n" + dir := t.TempDir() + sourcePath := filepath.Join(dir, "test.go") + require.NoError(t, os.WriteFile(sourcePath, []byte(source), 0644)) + + document := &scip.Document{ + RelativePath: "test.go", + Occurrences: []*scip.Occurrence{ + { + Symbol: "local foo", + SymbolRoles: int32(scip.SymbolRole_Definition), + TypedRange: rangeAt(0, 8, 0, 11).AsTypedRange(), + }, + { + // Multiline reference spanning lines 1..3. + Symbol: "local bar", + TypedRange: rangeAt(1, 5, 3, 1).AsTypedRange(), + }, + { + // Zero-width definition: still renders a single marker. + Symbol: "local zero", + SymbolRoles: int32(scip.SymbolRole_Definition), + TypedRange: rangeAt(2, 2, 2, 2).AsTypedRange(), + }, + }, + Symbols: []*scip.SymbolInformation{ + { + Symbol: "local foo", + Kind: scip.SymbolInformation_Namespace, + DisplayName: "foo", + EnclosingSymbol: "local root", + }, + { + // Defined at "local foo"'s definition site; rendered as a + // synthetic_definition under it. + Symbol: "local child", + Kind: scip.SymbolInformation_Field, + DisplayName: "child", + Relationships: []*scip.Relationship{{Symbol: "local foo", IsDefinition: true}}, + }, + }, + } + + snapshot, err := FormatSnapshot(document, "//", scip.DescriptorOnlyFormatter, sourcePath) + require.NoError(t, err) + + // enclosing_symbol is rendered through the symbol formatter. + require.Contains(t, snapshot, "// ^^^ definition local foo") + require.Contains(t, snapshot, "enclosing_symbol local root") + + // Synthetic definition: distinct child symbol, underscore markers, and the + // is_definition relationship rendered as "definition". + require.Contains(t, snapshot, "// ___ synthetic_definition local child") + require.Contains(t, snapshot, "relationship local foo definition") + + // Multiline occurrence: markers extend to end of the start line and a + // ":" suffix reports the end position. + require.Contains(t, snapshot, "// ^^^^^^^ reference local bar 2:1") + + // Zero-width range still renders exactly one marker. + require.Contains(t, snapshot, "// ^ definition local zero") +} diff --git a/reprolang/testdata/snapshots/output/relationships/defined_by.repro b/reprolang/testdata/snapshots/output/relationships/defined_by.repro index c01ad75b..c1925543 100755 --- a/reprolang/testdata/snapshots/output/relationships/defined_by.repro +++ b/reprolang/testdata/snapshots/output/relationships/defined_by.repro @@ -7,6 +7,9 @@ # ^^^^^ definition defined_by.repro/C1_f. # documentation # > signature of C1_f. +# _____ synthetic_definition defined_by.repro/C1_f. +# relationship defined_by.repro/C1_f. definition +# relationship defined_by.repro/M_f. reference reference C2_f. # ^^^^^ reference defined_by.repro/C1_f. From 77a23434efef354a6a7b400a33d5f1acd39519b5 Mon Sep 17 00:00:00 2001 From: jupblb Date: Thu, 18 Jun 2026 14:41:55 +0200 Subject: [PATCH 2/3] scip test: support synthetic_definition and multiline occurrences Recognize the '_' marker and synthetic_definition kind (derived from is_definition relationships), parse the multiline : suffix, and ignore range-less snapshot metadata lines so snapshot blocks can be pasted into test files. Fix an index-out-of-range panic when the expected symbol has more parts than the actual. Share marker/synthetic helpers with the snapshot formatter. --- bindings/go/scip/testutil/format.go | 82 +++-- bindings/go/scip/testutil/test_runner.go | 304 ++++++++++++++---- bindings/go/scip/testutil/test_runner_test.go | 124 +++++++ docs/test_file_format.md | 29 +- 4 files changed, 437 insertions(+), 102 deletions(-) diff --git a/bindings/go/scip/testutil/format.go b/bindings/go/scip/testutil/format.go index 5b6d5e0c..9396490b 100644 --- a/bindings/go/scip/testutil/format.go +++ b/bindings/go/scip/testutil/format.go @@ -100,23 +100,10 @@ func FormatSnapshot( enclosingByEndLine := enclosingRangesByEndLine(enclosingRanges) // syntheticDefinitions maps a "parent" symbol to the SymbolInformation - // entries that declare a definition relationship on it (i.e. symbols whose - // definition is synthesized at the parent's definition site). These are - // rendered as additional `synthetic_definition` occurrences underneath the - // parent definition occurrence. - syntheticDefinitions := map[string][]*scip.SymbolInformation{} - for _, info := range document.Symbols { - for _, rel := range info.Relationships { - if rel.IsDefinition { - syntheticDefinitions[rel.Symbol] = append(syntheticDefinitions[rel.Symbol], info) - } - } - } - for _, infos := range syntheticDefinitions { - sort.SliceStable(infos, func(i, j int) bool { - return infos[i].Symbol < infos[j].Symbol - }) - } + // entries whose definition is synthesized at the parent's definition site. + // These are rendered as additional `synthetic_definition` occurrences + // underneath the parent definition occurrence. + syntheticDefinitions := syntheticDefinitionsByParent(document.Symbols) // formatOccurrence renders a single occurrence and its associated metadata. // When synthetic is non-nil, the occurrence is rendered as a @@ -135,17 +122,10 @@ func FormatSnapshot( b.WriteRune(' ') } - markerCount := pos.End.Character - pos.Start.Character - if !pos.IsSingleLine() { - // Multiline occurrences are anchored to their start line and the - // markers extend to the end of that line. The end position is - // reported via the `:` suffix below. - markerCount = int32(len(renderedLine)) - pos.Start.Character - } - if markerCount < 1 { - // SCIP allows empty ranges; always render at least one marker. - markerCount = 1 - } + // Multiline occurrences are anchored to their start line and the + // markers extend to the end of that line. The end position is reported + // via the `:` suffix below. + markerCount := markerLength(pos, renderedLine) for c := int32(0); c < markerCount; c++ { b.WriteByte(marker) } @@ -238,8 +218,7 @@ func FormatSnapshot( b.WriteString("\n") } - line = strings.TrimSuffix(line, "\r") - renderedLine := strings.ReplaceAll(line, "\t", " ") + renderedLine := renderLine(line) b.WriteString(strings.Repeat(" ", len(commentSyntax))) b.WriteString(renderedLine) b.WriteString("\n") @@ -403,3 +382,46 @@ func enclosingRangesByEndLine(ranges []enclosingRange) map[int32][]enclosingRang } return result } + +// syntheticDefinitionsByParent maps a "parent" symbol to the SymbolInformation +// entries that declare a definition relationship on it (i.e. symbols whose +// definition is synthesized at the parent's definition site). The slices are +// sorted by symbol for deterministic output. This is shared by the snapshot +// formatter and the `scip test` runner so the two cannot drift apart. +func syntheticDefinitionsByParent(symbols []*scip.SymbolInformation) map[string][]*scip.SymbolInformation { + result := map[string][]*scip.SymbolInformation{} + for _, info := range symbols { + for _, rel := range info.Relationships { + if rel.IsDefinition { + result[rel.Symbol] = append(result[rel.Symbol], info) + } + } + } + for _, infos := range result { + sort.SliceStable(infos, func(i, j int) bool { + return infos[i].Symbol < infos[j].Symbol + }) + } + return result +} + +// renderLine normalizes a source line for snapshot/test rendering by trimming a +// trailing carriage return and expanding tabs to single spaces. +func renderLine(line string) string { + return strings.ReplaceAll(strings.TrimSuffix(line, "\r"), "\t", " ") +} + +// markerLength returns the number of caret/underscore markers used to underline +// an occurrence. Single-line occurrences are underlined exactly; multiline +// occurrences are underlined from their start column to the end of the rendered +// start line. SCIP allows empty ranges, so at least one marker is always drawn. +func markerLength(pos scip.Range, renderedStartLine string) int32 { + length := pos.End.Character - pos.Start.Character + if !pos.IsSingleLine() { + length = int32(len(renderedStartLine)) - pos.Start.Character + } + if length < 1 { + length = 1 + } + return length +} diff --git a/bindings/go/scip/testutil/test_runner.go b/bindings/go/scip/testutil/test_runner.go index 41155fbf..276708cc 100644 --- a/bindings/go/scip/testutil/test_runner.go +++ b/bindings/go/scip/testutil/test_runner.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "slices" + "strconv" "strings" "github.com/fatih/color" @@ -71,6 +72,8 @@ func RunTests( failures := []string{} successCount := 0 + syntheticDefinitions := syntheticDefinitionsByParent(document.Symbols) + lines := strings.Split(string(data), "\n") for lineNumber := 0; lineNumber < len(lines); lineNumber++ { testCasesAtLine, usedLines := testCasesForLine(lineNumber, lines, commentSyntax) @@ -81,7 +84,12 @@ func RunTests( continue } - attributes := attributesForOccurrencesAtLine(lineNumber, document.Occurrences) + attributes := attributesForOccurrencesAtLine( + lineNumber, + renderLine(lines[lineNumber]), + document.Occurrences, + syntheticDefinitions, + ) for _, testCase := range testCasesAtLine { filteredAttrs := filterAttributesForTestCase(testCase, attributes) if !testCase.checkAll(filteredAttrs) { @@ -149,15 +157,37 @@ type symbolAttribute struct { // each line should be considered a "newline", and is used for multiline // comments (in documentation), and diagnostic information additionalData []string + + // hasEndPosition is true when this attribute describes a multiline + // occurrence and the end position fields below are populated. + hasEndPosition bool + + // endLineDelta is the number of lines between the occurrence's start and end + // line (always > 0 when hasEndPosition is true). + endLineDelta int + + // endCharacter is the occurrence's end character on its end line. + endCharacter int +} + +// displayData renders the attribute's data for failure messages, appending the +// `:` suffix for multiline occurrences so the output +// matches the `scip snapshot` representation. +func (a symbolAttribute) displayData() string { + if a.hasEndPosition { + return fmt.Sprintf("%s %d:%d", a.data, a.endLineDelta, a.endCharacter) + } + return a.data } type symbolAttributeKind string const ( - definitionAttrKind symbolAttributeKind = "definition" - referenceAttrKind symbolAttributeKind = "reference" - forwardDefinitionAttrKind symbolAttributeKind = "forward_definition" - diagnosticAttrKind symbolAttributeKind = "diagnostic" + definitionAttrKind symbolAttributeKind = "definition" + referenceAttrKind symbolAttributeKind = "reference" + forwardDefinitionAttrKind symbolAttributeKind = "forward_definition" + syntheticDefinitionAttrKind symbolAttributeKind = "synthetic_definition" + diagnosticAttrKind symbolAttributeKind = "diagnostic" ) func symbolAttributeKindFromStr(str string) symbolAttributeKind { @@ -168,6 +198,8 @@ func symbolAttributeKindFromStr(str string) symbolAttributeKind { return referenceAttrKind case "forward_definition": return forwardDefinitionAttrKind + case "synthetic_definition": + return syntheticDefinitionAttrKind case "diagnostic": return diagnosticAttrKind default: @@ -204,7 +236,13 @@ func testCasesForLine(lineNumber int, lines []string, commentSyntax string) ([]s if !strings.HasPrefix(strings.TrimSpace(line), commentSyntax) { break } - testCase := parseTestCase(line, lines[i+1:], commentSyntax) + testCase, ok := parseTestCase(line, lines[i+1:], commentSyntax) + if !ok { + // Non-assertion comment line (e.g. snapshot metadata): consume and + // ignore it, but keep scanning the rest of the comment block. + usedLines += 1 + continue + } testLines = append(testLines, testCase) i += len(testCase.attribute.additionalData) @@ -224,79 +262,186 @@ func filterAttributesForTestCase(testCase symbolAttributeTestCase, attributes [] return filteredAttrs } -func attributesForOccurrencesAtLine(lineNumber int, occurrences []*scip.Occurrence) []symbolAttribute { +// attributeForRange builds a symbolAttribute for the given occurrence range, +// computing the marker length (and end position for multiline ranges) the same +// way the snapshot formatter does. +func attributeForRange(pos scip.Range, renderedLine string, kind symbolAttributeKind, data string) symbolAttribute { + attr := symbolAttribute{ + start: int(pos.Start.Character), + length: int(markerLength(pos, renderedLine)), + kind: kind, + data: data, + additionalData: []string{}, + } + if !pos.IsSingleLine() { + attr.hasEndPosition = true + attr.endLineDelta = int(pos.End.Line - pos.Start.Line) + attr.endCharacter = int(pos.End.Character) + } + return attr +} + +func attributesForOccurrencesAtLine( + lineNumber int, + renderedLine string, + occurrences []*scip.Occurrence, + syntheticDefinitions map[string][]*scip.SymbolInformation, +) []symbolAttribute { result := []symbolAttribute{} for _, occ := range occurrences { pos, ok := occ.SourceRange() if !ok { continue } - if pos.Start.Line == int32(lineNumber) { - start := int(pos.Start.Character) - length := int(pos.End.Character - pos.Start.Character) - - kind := referenceAttrKind - if scip.SymbolRole_Definition.Matches(occ) { - kind = definitionAttrKind - } else if scip.SymbolRole_ForwardDefinition.Matches(occ) { - kind = forwardDefinitionAttrKind - } - result = append(result, symbolAttribute{ - start: start, - length: length, - kind: kind, - data: occ.Symbol, - additionalData: []string{}, - }) - - for _, diagnostic := range occ.Diagnostics { - result = append(result, symbolAttribute{ - start: start, - length: length, - kind: diagnosticAttrKind, - data: diagnostic.Severity.String(), - additionalData: []string{ - diagnostic.Message, - }, - }) + if pos.Start.Line != int32(lineNumber) { + continue + } + + isDefinition := scip.SymbolRole_Definition.Matches(occ) + kind := referenceAttrKind + if isDefinition { + kind = definitionAttrKind + } else if scip.SymbolRole_ForwardDefinition.Matches(occ) { + kind = forwardDefinitionAttrKind + } + result = append(result, attributeForRange(pos, renderedLine, kind, occ.Symbol)) + + for _, diagnostic := range occ.Diagnostics { + diag := attributeForRange(pos, renderedLine, diagnosticAttrKind, diagnostic.Severity.String()) + diag.additionalData = []string{diagnostic.Message} + result = append(result, diag) + } + + // Synthetic definitions are projected onto a real definition's range, + // mirroring the `scip snapshot` output. + if isDefinition { + for _, synthetic := range syntheticDefinitions[occ.Symbol] { + result = append(result, attributeForRange(pos, renderedLine, syntheticDefinitionAttrKind, synthetic.Symbol)) } } } return result } -func parseTestCase(line string, leadingLines []string, commentSyntax string) symbolAttributeTestCase { - start := 0 - length := 0 - enforceLength := false +type rangeMarker byte - if strings.Contains(line, "<-") { - // if the test line selects via `<-`, treat the symbol selection - // as the location of the commentSyntax - start = strings.Index(line, commentSyntax) - line = strings.Replace(line, "<-", "", 1) - } else { - // otherwise treat the start as the first `^` - start = strings.Index(line, "^") - - // a single `^` dictates no length enforcement - // anything more signifies length should be verified - if strings.Contains(line, "^^") { - enforceLength = true - length = strings.Count(line, "^") - } - line = strings.ReplaceAll(line, "^", "") +const ( + caretMarker rangeMarker = '^' + underscoreMarker rangeMarker = '_' +) + +type rangeSelection struct { + start int + length int + enforceLength bool + marker rangeMarker + // content is the test line with the comment prefix and range markers removed. + content string +} + +// parseRangeSelection extracts the range-selection portion of a test comment +// line. Ranges are selected with `<-`, a run of `^` (caret), or a run of `_` +// (underscore, used by `synthetic_definition`). It returns ok=false for comment +// lines that contain no range selection, such as snapshot metadata detail lines +// (`kind`, `display_name`, `enclosing_symbol`, `relationship`, `>` ...), which +// the test runner ignores rather than asserts. +func parseRangeSelection(line, commentSyntax string) (rangeSelection, bool) { + commentIdx := strings.Index(line, commentSyntax) + if commentIdx < 0 { + return rangeSelection{}, false + } + body := line[commentIdx+len(commentSyntax):] + trimmed := strings.TrimLeft(body, " \t") + leading := len(body) - len(trimmed) + + // `<-` selects the character above the first comment character, similar to + // Sublime Text syntax tests. + if strings.HasPrefix(trimmed, "<-") { + return rangeSelection{ + start: commentIdx, + marker: caretMarker, + content: strings.TrimPrefix(trimmed, "<-"), + }, true + } + + if trimmed == "" { + return rangeSelection{}, false + } + marker := rangeMarker(trimmed[0]) + if marker != caretMarker && marker != underscoreMarker { + return rangeSelection{}, false + } + + // Count only the contiguous marker run. This matters because `_` also + // appears inside kinds like `synthetic_definition` and `forward_definition`. + runLen := 0 + for runLen < len(trimmed) && rangeMarker(trimmed[runLen]) == marker { + runLen++ + } + + sel := rangeSelection{ + start: commentIdx + len(commentSyntax) + leading, + marker: marker, + content: trimmed[runLen:], + } + // A single marker ignores length; two or more enforce it. + if runLen >= 2 { + sel.enforceLength = true + sel.length = runLen + } + return sel, true +} + +// parseMultilineSuffix splits a trailing `:` token off +// of the test data. The suffix is only recognized when lineDelta > 0, matching +// the multiline occurrences emitted by `scip snapshot`. +func parseMultilineSuffix(data string) (rest string, endLineDelta int, endCharacter int, ok bool) { + fields := strings.Fields(data) + if len(fields) == 0 { + return data, 0, 0, false + } + last := fields[len(fields)-1] + colon := strings.IndexByte(last, ':') + if colon <= 0 || colon == len(last)-1 { + return data, 0, 0, false } + lineDelta, err1 := strconv.Atoi(last[:colon]) + endChar, err2 := strconv.Atoi(last[colon+1:]) + if err1 != nil || err2 != nil || lineDelta <= 0 || endChar < 0 { + return data, 0, 0, false + } + rest = strings.TrimSpace(strings.TrimSuffix(data, last)) + return rest, lineDelta, endChar, true +} - // remove the comment prefix & whitespace - line = strings.TrimSpace(strings.Replace(line, commentSyntax, "", 1)) +// parseTestCase parses a single test comment line. It returns ok=false for +// comment lines that are not assertions (e.g. snapshot metadata detail lines), +// which the caller skips. +func parseTestCase(line string, leadingLines []string, commentSyntax string) (symbolAttributeTestCase, bool) { + sel, ok := parseRangeSelection(line, commentSyntax) + if !ok { + return symbolAttributeTestCase{}, false + } - // the type of the symbol should be the first word - // this is "definition", "reference", "documentation", "diagnostic", etc.. - kindStr := strings.Split(line, " ")[0] + content := strings.TrimSpace(sel.content) + fields := strings.Fields(content) + if len(fields) == 0 { + // A range marker with no kind is not a valid test case; ignore it. + return symbolAttributeTestCase{}, false + } + + // the type of the symbol is the first word, e.g. "definition", "reference", + // "synthetic_definition", "forward_definition", "diagnostic". + kindStr := fields[0] + kind := symbolAttributeKindFromStr(kindStr) + + if sel.marker == underscoreMarker && kind != syntheticDefinitionAttrKind { + panic(fmt.Sprintf("'_' range marker is only valid for synthetic_definition, got %q", kindStr)) + } - // the data is everything except the type - data := strings.TrimSpace(strings.Replace(line, kindStr, "", 1)) + // the data is everything after the type, minus any multiline suffix + data := strings.TrimSpace(strings.TrimPrefix(content, kindStr)) + data, endLineDelta, endCharacter, hasEndPosition := parseMultilineSuffix(data) additionalData := []string{} for i := range leadingLines { @@ -321,14 +466,17 @@ func parseTestCase(line string, leadingLines []string, commentSyntax string) sym return symbolAttributeTestCase{ attribute: symbolAttribute{ - kind: symbolAttributeKindFromStr(kindStr), - start: start, - length: length, + kind: kind, + start: sel.start, + length: sel.length, data: data, additionalData: additionalData, + hasEndPosition: hasEndPosition, + endLineDelta: endLineDelta, + endCharacter: endCharacter, }, - enforceLength: enforceLength, - } + enforceLength: sel.enforceLength, + }, true } func (s symbolAttributeTestCase) checkAll(attributes []symbolAttribute) bool { @@ -357,8 +505,11 @@ func (s symbolAttributeTestCase) check(attr symbolAttribute) bool { // check if symbols are equal, a `.` character in the testCaseSymbol is considered // a wildcard, and matches the correlating group - testCaseSymbolParts := strings.Split(s.attribute.data, " ") - attrSymbolParts := strings.Split(attr.data, " ") + testCaseSymbolParts := strings.Fields(s.attribute.data) + attrSymbolParts := strings.Fields(attr.data) + if len(testCaseSymbolParts) != len(attrSymbolParts) { + return false + } for i, testCaseSymbolPart := range testCaseSymbolParts { if testCaseSymbolPart == "." { continue @@ -368,6 +519,17 @@ func (s symbolAttributeTestCase) check(attr symbolAttribute) bool { } } + // when the test specifies a multiline end position (`:`), + // require the occurrence to match it. Tests that omit the suffix do not + // constrain the end position. + if s.attribute.hasEndPosition { + if !attr.hasEndPosition || + s.attribute.endLineDelta != attr.endLineDelta || + s.attribute.endCharacter != attr.endCharacter { + return false + } + } + // only validate additionalData if the testCases provides one // otherwise, ignore what the attribute specifies if len(s.attribute.additionalData) > 0 { @@ -382,7 +544,7 @@ func (s symbolAttributeTestCase) check(attr symbolAttribute) bool { func formatFailure(lineNumber int, testCase symbolAttributeTestCase, attributesAtLine []symbolAttribute) string { failureDesc := []string{ fmt.Sprintf("Failure [Ln: %d, Col: %d]", lineNumber+1, testCase.attribute.start), - fmt.Sprintf(" Expected: '%s %s'", testCase.attribute.kind, testCase.attribute.data), + fmt.Sprintf(" Expected: '%s %s'", testCase.attribute.kind, testCase.attribute.displayData()), } for _, add := range testCase.attribute.additionalData { failureDesc = append(failureDesc, indent(fmt.Sprintf("'%s'", add), 12)) @@ -403,7 +565,7 @@ func formatFailure(lineNumber int, testCase symbolAttributeTestCase, attributesA prefix += " " } - failureDesc = append(failureDesc, fmt.Sprintf(" %s '%s %s'", prefix, attr.kind, attr.data)) + failureDesc = append(failureDesc, fmt.Sprintf(" %s '%s %s'", prefix, attr.kind, attr.displayData())) for _, add := range attr.additionalData { failureDesc = append(failureDesc, indent(fmt.Sprintf("'%s'", add), 12)) } diff --git a/bindings/go/scip/testutil/test_runner_test.go b/bindings/go/scip/testutil/test_runner_test.go index 00f8b71b..b00ea74c 100644 --- a/bindings/go/scip/testutil/test_runner_test.go +++ b/bindings/go/scip/testutil/test_runner_test.go @@ -1,10 +1,16 @@ package testutil import ( + "bytes" + "os" + "path/filepath" "slices" + "strings" "testing" "github.com/stretchr/testify/require" + + "github.com/scip-code/scip/bindings/go/scip" ) func TestTestCasesForLine(t *testing.T) { @@ -59,3 +65,121 @@ func TestTestCasesForLine(t *testing.T) { ), ) } + +func TestParseTestCaseSyntheticDefinition(t *testing.T) { + tc, ok := parseTestCase("// ___ synthetic_definition local child", []string{}, "//") + require.True(t, ok) + require.Equal(t, syntheticDefinitionAttrKind, tc.attribute.kind) + require.Equal(t, "local child", tc.attribute.data) + require.True(t, tc.enforceLength) + // only the contiguous run of 3 underscores counts, not the '_' inside the + // "synthetic_definition" kind. + require.Equal(t, 3, tc.attribute.length) + require.Equal(t, 5, tc.attribute.start) + require.False(t, tc.attribute.hasEndPosition) +} + +func TestParseTestCaseMultilineSuffix(t *testing.T) { + tc, ok := parseTestCase("// ^^^^^ reference local bar 2:1", []string{}, "//") + require.True(t, ok) + require.Equal(t, referenceAttrKind, tc.attribute.kind) + require.Equal(t, "local bar", tc.attribute.data) + require.True(t, tc.attribute.hasEndPosition) + require.Equal(t, 2, tc.attribute.endLineDelta) + require.Equal(t, 1, tc.attribute.endCharacter) +} + +func TestParseTestCaseUnderscoreRejectsNonSynthetic(t *testing.T) { + require.Panics(t, func() { + parseTestCase("// ___ reference local child", []string{}, "//") + }) +} + +func TestParseTestCaseIgnoresMetadataLines(t *testing.T) { + for _, line := range []string{ + "// kind Namespace", + "// display_name foo", + "// enclosing_symbol local root", + "// relationship local foo definition", + "// > documentation line", + } { + _, ok := parseTestCase(line, []string{}, "//") + require.Falsef(t, ok, "expected %q to be ignored", line) + } +} + +func TestCheckSymbolPartCountMismatch(t *testing.T) { + tc := symbolAttributeTestCase{ + attribute: symbolAttribute{ + kind: definitionAttrKind, + start: 0, + length: 3, + data: "scheme manager name 1.0.0 Foo#", + }, + enforceLength: true, + } + attr := symbolAttribute{ + kind: definitionAttrKind, + start: 0, + length: 3, + data: "local 0", + } + // Must not panic, and must not match (different number of symbol parts). + require.False(t, tc.check(attr)) +} + +func TestRunTestsSyntheticAndMultiline(t *testing.T) { + t.Setenv("NO_COLOR", "1") + + index := &scip.Index{ + Documents: []*scip.Document{{ + RelativePath: "test.txt", + Occurrences: []*scip.Occurrence{ + { + Symbol: "local foo", + SymbolRoles: int32(scip.SymbolRole_Definition), + TypedRange: rangeAt(0, 3, 0, 6).AsTypedRange(), + }, + { + Symbol: "local bar", + TypedRange: rangeAt(3, 3, 5, 1).AsTypedRange(), + }, + }, + Symbols: []*scip.SymbolInformation{ + {Symbol: "local foo"}, + { + Symbol: "local child", + Relationships: []*scip.Relationship{{Symbol: "local foo", IsDefinition: true}}, + }, + }, + }}, + } + + passContent := strings.Join([]string{ + " foo", + "// ^^^ definition local foo", + "// ___ synthetic_definition local child", + " barbar", + "// ^^^^^^ reference local bar 2:1", + "mid", + "x", + }, "\n") + + dir := t.TempDir() + testFile := filepath.Join(dir, "test.txt") + require.NoError(t, os.WriteFile(testFile, []byte(passContent), 0644)) + + var passOut bytes.Buffer + require.NoError(t, RunTests(dir, nil, false, index, "//", &passOut)) + require.Contains(t, passOut.String(), "✓ test.txt (3 assertions)") + + // A wrong multiline end position must fail and report the actual suffix. + failContent := strings.Replace(passContent, + "reference local bar 2:1", "reference local bar 2:2", 1) + require.NoError(t, os.WriteFile(testFile, []byte(failContent), 0644)) + + var failOut bytes.Buffer + require.Error(t, RunTests(dir, nil, false, index, "//", &failOut)) + require.Contains(t, failOut.String(), "reference local bar 2:2") // expected + require.Contains(t, failOut.String(), "reference local bar 2:1") // actual +} diff --git a/docs/test_file_format.md b/docs/test_file_format.md index c7a3550b..f7edad01 100644 --- a/docs/test_file_format.md +++ b/docs/test_file_format.md @@ -23,13 +23,17 @@ function someFunction() { } ``` +The `_` marker behaves exactly like `^` but is reserved for `synthetic_definition` +test cases (see below), mirroring the `scip snapshot` output. + ### Type and Data -There are four possible types test cases. The chosen test case is determined by the first word after the range selection +There are five possible types of test cases. The chosen test case is determined by the first word after the range selection - `definition [symbol]` - validates that the specified range has a symbol with the role of "definition" with the specified `[symbol]` - `reference [symbol]` - validates that the specified range has a symbol with the role of "reference" with the specified `[symbol]` - `forward_definition [symbol]` - validates that the specified range has a symbol with the role of "forward_definition" with the specified `[symbol]` +- `synthetic_definition [symbol]` - validates that the specified range has a synthetic definition for `[symbol]`. Synthetic definitions are derived from `SymbolInformation` entries that declare an `is_definition` relationship, and are projected onto the range of the related definition occurrence. Use the `_` range marker for these. - `diagnostic [severity] [message]` - validates that the specified range has a diagnostic with the given `[severity]` and `[message]` ```js @@ -52,3 +56,26 @@ function someFn() { // > remove it or use it. } ``` + +### Multiline occurrences + +Occurrences whose range spans multiple lines are anchored to their start line. +The markers cover the start line, and an optional `:` +suffix after the symbol asserts the end position, where `lineDelta` is the +number of lines between the start and end line. When the suffix is omitted, the +end position is not checked. + +```js +const value = compute( + // ^^^^^^^ reference scip-typescript npm test_package 1.0.0 lib/`test.js`/compute(). 1:1 + arg, +) +``` + +### Ignored lines + +`scip test` only validates occurrence-level assertions and diagnostics. Snapshot +detail lines for symbol metadata such as `kind`, `display_name`, `documentation`, +`signature_documentation`, `relationship`, and `enclosing_symbol` carry no range +marker and are ignored, so a block of `scip snapshot` output can be pasted into a +test file without those lines causing failures. From f66be7fdc392d8178084cfb7eaab780bef5825a0 Mon Sep 17 00:00:00 2001 From: jupblb Date: Thu, 18 Jun 2026 14:50:40 +0200 Subject: [PATCH 3/3] docs: prettier-format test_file_format.md --- docs/test_file_format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/test_file_format.md b/docs/test_file_format.md index f7edad01..7dbd6b6f 100644 --- a/docs/test_file_format.md +++ b/docs/test_file_format.md @@ -68,7 +68,7 @@ end position is not checked. ```js const value = compute( // ^^^^^^^ reference scip-typescript npm test_package 1.0.0 lib/`test.js`/compute(). 1:1 - arg, + arg ) ```