Repository: ogen-go/ogen
Pull request #458 (2022-07-05 14:34:00)

feat(jsonschema): wrap errors with line and column


Patch:
diff --git a/json/location.go b/json/location.go
index ba631d47..4527ccf8 100644
--- a/json/location.go
+++ b/json/location.go
@@ -3,21 +3,64 @@ package json
 import (
 	"bytes"
 	"fmt"
+	"sort"
 	"strconv"
 
 	"github.com/go-json-experiment/json"
 )
 
+// Lines is a sorted slice of newline offsets.
+type Lines struct {
+	data []byte
+	// lines stores newline offsets.
+	//
+	// idx is the line number (counts from 0).
+	lines []int64
+}
+
+// Search returns the nearest line number to the given offset.
+//
+// NOTE: may return index bigger than lines length.
+func (l Lines) Search(offset int64) int64 {
+	// The index is the line number.
+	lines := l.lines
+	idx := sort.Search(len(lines), func(i int) bool {
+		return lines[i] >= offset
+	})
+	return int64(idx)
+}
+
+// Collect fills the given slice with the offset of newlines.
+func (l *Lines) Collect(data []byte) {
+	l.data = data
+	l.lines = l.lines[:0]
+
+	var (
+		// Remaining data to process.
+		remain = data
+		// Absolute offset of the current line.
+		offset = 0
+	)
+	for {
+		idx := bytes.IndexByte(remain, '\n')
+		if idx < 0 {
+			break
+		}
+		l.lines = append(l.lines, int64(offset+idx))
+		offset += idx + 1
+		remain = remain[idx+1:]
+	}
+}
+
 // LineColumn returns the line and column of the location.
 //
 // If offset is invalid, line and column are 0 and ok is false.
-func LineColumn(offset int64, data []byte) (line, column int64, ok bool) {
-	if offset < 0 || int64(len(data)) <= offset {
+func (l Lines) LineColumn(offset int64) (line, column int64, ok bool) {
+	if offset < 0 || offset >= int64(len(l.data)) {
 		return 0, 0, false
 	}
-
 	{
-		unread := data[offset:]
+		unread := l.data[offset:]
 		trimmed := bytes.TrimLeft(unread, "\x20\t\r\n,:")
 		if len(trimmed) != len(unread) {
 			// Skip leading whitespace, because decoder does not do it.
@@ -25,13 +68,20 @@ func LineColumn(offset int64, data []byte) (line, column int64, ok bool) {
 		}
 	}
 
-	lines := data[:offset]
-	// Lines count from 1.
-	line = int64(bytes.Count(lines, []byte("\n"))) + 1
-	// Find offset from last newline.
-	lastNL := int64(bytes.LastIndexByte(lines, '\n'))
-	column = offset - lastNL
-	return line, column, true
+	line = l.Search(offset)
+	if line > 0 {
+		var prevLine int64
+		if line-1 < int64(len(l.lines)) {
+			prevLine = l.lines[line-1]
+		}
+		column = offset - prevLine
+	} else {
+		// Offset is on the first line. Column counts from 1.
+		column = offset + 1
+	}
+
+	// Line counts from 1.
+	return line + 1, column, true
 }
 
 // Location is a JSON value location.
@@ -72,14 +122,14 @@ func (l Locator) Location() (Location, bool) {
 }
 
 // LocationUnmarshaler is json.Unmarshalers that sets the location.
-func LocationUnmarshaler(data []byte) *json.Unmarshalers {
+func LocationUnmarshaler(lines Lines) *json.Unmarshalers {
 	return json.UnmarshalFuncV2(func(opts json.UnmarshalOptions, d *json.Decoder, l Locatable) error {
 		if _, ok := l.(*Locator); ok {
 			return nil
 		}
 
 		offset := d.InputOffset()
-		line, column, _ := LineColumn(offset, data)
+		line, column, _ := lines.LineColumn(offset)
 		l.setLocation(Location{
 			JSONPointer: d.StackPointer(),
 			Offset:      offset,
diff --git a/jsonschema/errors.go b/jsonschema/errors.go
new file mode 100644
index 00000000..dc39d0d7
--- /dev/null
+++ b/jsonschema/errors.go
@@ -0,0 +1,71 @@
+package jsonschema
+
+import (
+	"fmt"
+
+	"github.com/go-faster/errors"
+
+	ogenjson "github.com/ogen-go/ogen/json"
+)
+
+var _ interface {
+	error
+	errors.Wrapper
+	errors.Formatter
+} = (*LocationError)(nil)
+
+// LocationError is a wrapper for an error that has a location.
+type LocationError struct {
+	file string
+	loc  ogenjson.Location
+	err  error
+}
+
+// Unwrap implements errors.Wrapper.
+func (e *LocationError) Unwrap() error {
+	return e.err
+}
+
+func (e *LocationError) fileName() string {
+	filename := e.file
+	if filename != "" {
+		switch {
+		case e.loc.Line != 0:
+			// Line is set, so return "${filename}:".
+			filename += ":"
+		case e.loc.JSONPointer != "":
+			// Line is not set, but JSONPointer is set, so return "${filename}#".
+			filename += "#"
+		default:
+			// Neither line nor JSONPointer is set, so return empty string.
+			return ""
+		}
+	}
+	return filename
+}
+
+// FormatError implements errors.Formatter.
+func (e *LocationError) FormatError(p errors.Printer) (next error) {
+	p.Printf("at %s%s", e.fileName(), e.loc)
+	return e.err
+}
+
+// Error implements error.
+func (e *LocationError) Error() string {
+	return fmt.Sprintf("at %s%s: %s", e.fileName(), e.loc, e.err)
+}
+
+func (p *Parser) wrapLocation(l ogenjson.Locatable, err error) error {
+	if err == nil || l == nil || p == nil {
+		return err
+	}
+	loc, ok := l.Location()
+	if !ok {
+		return err
+	}
+	return &LocationError{
+		file: p.filename,
+		loc:  loc,
+		err:  err,
+	}
+}
diff --git a/jsonschema/parser.go b/jsonschema/parser.go
index b697cee9..1a51e031 100644
--- a/jsonschema/parser.go
+++ b/jsonschema/parser.go
@@ -10,10 +10,13 @@ import (
 
 // Parser parses JSON schemas.
 type Parser struct {
-	external   ExternalResolver
-	schemas    map[string]ReferenceResolver
-	refcache   map[refKey]*Schema
+	external ExternalResolver
+	schemas  map[string]ReferenceResolver
+	refcache map[refKey]*Schema
+
 	depthLimit int
+	filename   string // optional, used for error messages
+
 	inferTypes bool
 }
 
@@ -27,6 +30,7 @@ func NewParser(s Settings) *Parser {
 		},
 		refcache:   map[refKey]*Schema{},
 		depthLimit: s.DepthLimit,
+		filename:   s.Filename,
 		inferTypes: s.InferTypes,
 	}
 }
@@ -41,7 +45,12 @@ func (p *Parser) Resolve(ref string) (*Schema, error) {
 	return p.resolve(ref, newResolveCtx(p.depthLimit))
 }
 
-func (p *Parser) parse(schema *RawSchema, ctx *resolveCtx) (*Schema, error) {
+func (p *Parser) parse(schema *RawSchema, ctx *resolveCtx) (_ *Schema, rerr error) {
+	if schema != nil {
+		defer func() {
+			rerr = p.wrapLocation(schema, rerr)
+		}()
+	}
 	return p.parse1(schema, ctx, func(s *Schema) *Schema {
 		return p.extendInfo(schema, s)
 	})
diff --git a/jsonschema/parser_settings.go b/jsonschema/parser_settings.go
index 32c50d8e..74ea7c1f 100644
--- a/jsonschema/parser_settings.go
+++ b/jsonschema/parser_settings.go
@@ -10,6 +10,11 @@ type Settings struct {
 	// Resolver is a root resolver.
 	Resolver ReferenceResolver
 
+	// Filename is a name of the file being parsed.
+	//
+	// Used for error messages.
+	Filename string
+
 	// DepthLimit limits the number of nested references. Default is 1000.
 	DepthLimit int
 
diff --git a/openapi/api.go b/openapi/api.go
index 188daae3..07c6b865 100644
--- a/openapi/api.go
+++ b/openapi/api.go
@@ -11,8 +11,9 @@ type API struct {
 
 // Components represent parsed components of OpenAPI spec.
 type Components struct {
-	Parameters    map[string]*Parameter
 	Schemas       map[string]*jsonschema.Schema
-	RequestBodies map[string]*RequestBody
 	Responses     map[string]*Response
+	Parameters    map[string]*Parameter
+	Examples      map[string]*Example
+	RequestBodies map[string]*RequestBody
 }
diff --git a/openapi/parser/errors.go b/openapi/parser/errors.go
index bb9df54e..e572137e 100644
--- a/openapi/parser/errors.go
+++ b/openapi/parser/errors.go
@@ -56,8 +56,8 @@ func (e *LocationError) Error() string {
 }
 
 func (p *parser) wrapLocation(l ogenjson.Locatable, err error) error {
-	if err == nil {
-		return nil
+	if err == nil || l == nil || p == nil {
+		return err
 	}
 	loc, ok := l.Location()
 	if !ok {
diff --git a/openapi/parser/parse_components.go b/openapi/parser/parse_components.go
index 00677698..1f88b37a 100644
--- a/openapi/parser/parse_components.go
+++ b/openapi/parser/parse_components.go
@@ -8,12 +8,38 @@ import (
 	"github.com/ogen-go/ogen/openapi"
 )
 
-func (p *parser) parseComponents(c *ogen.Components) (*openapi.Components, error) {
+func (p *parser) parseComponents(c *ogen.Components) (_ *openapi.Components, rerr error) {
 	result := &openapi.Components{
-		Parameters:    make(map[string]*openapi.Parameter, len(c.Parameters)),
 		Schemas:       make(map[string]*jsonschema.Schema, len(c.Schemas)),
-		RequestBodies: make(map[string]*openapi.RequestBody, len(c.RequestBodies)),
 		Responses:     make(map[string]*openapi.Response, len(c.Responses)),
+		Parameters:    make(map[string]*openapi.Parameter, len(c.Parameters)),
+		Examples:      make(map[string]*openapi.Example, len(c.Examples)),
+		RequestBodies: make(map[string]*openapi.RequestBody, len(c.RequestBodies)),
+	}
+	if c != nil {
+		defer func() {
+			rerr = p.wrapLocation(c, rerr)
+		}()
+	}
+
+	for name := range c.Schemas {
+		ref := "#/components/schemas/" + name
+		s, err := p.schemaParser.Resolve(ref)
+		if err != nil {
+			return nil, errors.Wrapf(err, "schemas: %q", name)
+		}
+
+		result.Schemas[name] = s
+	}
+
+	for name := range c.Responses {
+		ref := "#/components/responses/" + name
+		r, err := p.resolveResponse(ref, newResolveCtx(p.depthLimit))
+		if err != nil {
+			return nil, errors.Wrapf(err, "responses: %q", name)
+		}
+
+		result.Responses[name] = r
 	}
 
 	for name := range c.Parameters {
@@ -26,14 +52,14 @@ func (p *parser) parseComponents(c *ogen.Components) (*openapi.Components, error
 		result.Parameters[name] = pp
 	}
 
-	for name := range c.Schemas {
-		ref := "#/components/schemas/" + name
-		s, err := p.schemaParser.Resolve(ref)
+	for name := range c.Examples {
+		ref := "#/components/examples/" + name
+		ex, err := p.resolveExample(ref, newResolveCtx(p.depthLimit))
 		if err != nil {
-			return nil, errors.Wrapf(err, "schemas: %q", name)
+			return nil, errors.Wrapf(err, "examples: %q", name)
 		}
 
-		result.Schemas[name] = s
+		result.Examples[name] = ex
 	}
 
 	for name := range c.RequestBodies {
@@ -46,15 +72,5 @@ func (p *parser) parseComponents(c *ogen.Components) (*openapi.Components, error
 		result.RequestBodies[name] = b
 	}
 
-	for name := range c.Responses {
-		ref := "#/components/responses/" + name
-		r, err := p.resolveResponse(ref, newResolveCtx(p.depthLimit))
-		if err != nil {
-			return nil, errors.Wrapf(err, "responses: %q", name)
-		}
-
-		result.Responses[name] = r
-	}
-
 	return result, nil
 }
diff --git a/openapi/parser/parse_header.go b/openapi/parser/parse_header.go
index 39e63cc7..6f32ccb6 100644
--- a/openapi/parser/parse_header.go
+++ b/openapi/parser/parse_header.go
@@ -27,6 +27,9 @@ func (p *parser) parseHeader(name string, header *ogen.Header, ctx *resolveCtx)
 	if header == nil {
 		return nil, errors.New("header object is empty or null")
 	}
+	defer func() {
+		rerr = p.wrapLocation(header, rerr)
+	}()
 	if ref := header.Ref; ref != "" {
 		parsed, err := p.resolveHeader(name, ref, ctx)
 		if err != nil {
@@ -34,9 +37,6 @@ func (p *parser) parseHeader(name string, header *ogen.Header, ctx *resolveCtx)
 		}
 		return parsed, nil
 	}
-	defer func() {
-		rerr = p.wrapLocation(header, rerr)
-	}()
 
 	if header.In != "" {
 		return nil, errors.Errorf(`"in" MUST NOT be specified, got %q`, header.In)
diff --git a/openapi/parser/parse_mediatype.go b/openapi/parser/parse_mediatype.go
index 92ae81ce..af3081c9 100644
--- a/openapi/parser/parse_mediatype.go
+++ b/openapi/parser/parse_mediatype.go
@@ -73,10 +73,14 @@ func (p *parser) parseMediaType(m ogen.Media, ctx *resolveCtx) (_ *openapi.Media
 			names[prop.Name] = prop
 		}
 
-		parseEncoding := func(name string, e ogen.Encoding) error {
+		parseEncoding := func(name string, e ogen.Encoding) (rerr error) {
+			defer func() {
+				rerr = p.wrapLocation(&e, rerr)
+			}()
+
 			prop, ok := names[name]
 			if !ok {
-				return errors.New("unknown prop")
+				return errors.Errorf("unknown property %q", name)
 			}
 
 			encoding := &openapi.Encoding{
diff --git a/openapi/parser/parse_parameter.go b/openapi/parser/parse_parameter.go
index e66e69d9..8b56c174 100644
--- a/openapi/parser/parse_parameter.go
+++ b/openapi/parser/parse_parameter.go
@@ -38,7 +38,8 @@ func (p *parser) parseParams(params []*ogen.Parameter) ([]*openapi.Parameter, er
 			location: param.In,
 		}
 		if _, ok := unique[ploc]; ok {
-			return nil, errors.Errorf("duplicate parameter: %q in %q", param.Name, param.In)
+			err = errors.Errorf("duplicate parameter: %q in %q", param.Name, param.In)
+			return nil, p.wrapLocation(spec, err)
 		}
 
 		unique[ploc] = struct{}{}
@@ -77,6 +78,9 @@ func (p *parser) parseParameter(param *ogen.Parameter, ctx *resolveCtx) (_ *open
 	if param == nil {
 		return nil, errors.New("parameter object is empty or null")
 	}
+	defer func() {
+		rerr = p.wrapLocation(param, rerr)
+	}()
 	if ref := param.Ref; ref != "" {
 		parsed, err := p.resolveParameter(ref, ctx)
 		if err != nil {
@@ -84,9 +88,6 @@ func (p *parser) parseParameter(param *ogen.Parameter, ctx *resolveCtx) (_ *open
 		}
 		return parsed, nil
 	}
-	defer func() {
-		rerr = p.wrapLocation(param, rerr)
-	}()
 
 	types := map[string]openapi.ParameterLocation{
 		"query":  openapi.LocationQuery,
diff --git a/openapi/parser/parse_request_body.go b/openapi/parser/parse_request_body.go
index dc99afee..1a5f773e 100644
--- a/openapi/parser/parse_request_body.go
+++ b/openapi/parser/parse_request_body.go
@@ -11,6 +11,9 @@ func (p *parser) parseRequestBody(body *ogen.RequestBody, ctx *resolveCtx) (_ *o
 	if body == nil {
 		return nil, errors.New("requestBody object is empty or null")
 	}
+	defer func() {
+		rerr = p.wrapLocation(body, rerr)
+	}()
 	if ref := body.Ref; ref != "" {
 		reqBody, err := p.resolveRequestBody(ref, ctx)
 		if err != nil {
@@ -19,9 +22,6 @@ func (p *parser) parseRequestBody(body *ogen.RequestBody, ctx *resolveCtx) (_ *o
 
 		return reqBody, nil
 	}
-	defer func() {
-		rerr = p.wrapLocation(body, rerr)
-	}()
 
 	if len(body.Content) < 1 {
 		// See https://github.com/OAI/OpenAPI-Specification/discussions/2875.
diff --git a/openapi/parser/parse_response.go b/openapi/parser/parse_response.go
index 04b7a1b7..b3ab6a45 100644
--- a/openapi/parser/parse_response.go
+++ b/openapi/parser/parse_response.go
@@ -42,6 +42,9 @@ func (p *parser) parseResponse(resp *ogen.Response, ctx *resolveCtx) (_ *openapi
 	if resp == nil {
 		return nil, errors.New("response object is empty or null")
 	}
+	defer func() {
+		rerr = p.wrapLocation(resp, rerr)
+	}()
 	if ref := resp.Ref; ref != "" {
 		resp, err := p.resolveResponse(ref, ctx)
 		if err != nil {
@@ -50,9 +53,6 @@ func (p *parser) parseResponse(resp *ogen.Response, ctx *resolveCtx) (_ *openapi
 
 		return resp, nil
 	}
-	defer func() {
-		rerr = p.wrapLocation(resp, rerr)
-	}()
 
 	content, err := p.parseContent(resp.Content, ctx)
 	if err != nil {
diff --git a/openapi/parser/parse_schema.go b/openapi/parser/parse_schema.go
index 7d5b2af2..d7cb8e11 100644
--- a/openapi/parser/parse_schema.go
+++ b/openapi/parser/parse_schema.go
@@ -10,12 +10,9 @@ import (
 	"github.com/ogen-go/ogen/jsonschema"
 )
 
-func (p *parser) parseSchema(schema *ogen.Schema, ctx *resolveCtx) (_ *jsonschema.Schema, rerr error) {
-	if schema != nil {
-		defer func() {
-			rerr = p.wrapLocation(schema, rerr)
-		}()
-	}
+func (p *parser) parseSchema(schema *ogen.Schema, ctx *resolveCtx) (*jsonschema.Schema, error) {
+	// jsonschema.Parser will add location to the error.
+
 	s := schema.ToJSONSchema()
 	if loc := ctx.lastLoc(); s != nil && s.Ref != "" && loc != "" {
 		base, err := url.Parse(loc)
diff --git a/openapi/parser/parse_security.go b/openapi/parser/parse_security.go
index dc16d371..ca9e0416 100644
--- a/openapi/parser/parse_security.go
+++ b/openapi/parser/parse_security.go
@@ -18,6 +18,9 @@ func (p *parser) parseSecurityScheme(
 	if scheme == nil {
 		return nil, errors.New("securityScheme is empty or null")
 	}
+	defer func() {
+		rerr = p.wrapLocation(scheme, rerr)
+	}()
 
 	if ref := scheme.Ref; ref != "" {
 		sch, err := p.resolveSecurityScheme(ref, ctx)
@@ -26,9 +29,6 @@ func (p *parser) parseSecurityScheme(
 		}
 		return sch, nil
 	}
-	defer func() {
-		rerr = p.wrapLocation(scheme, rerr)
-	}()
 
 	if err := func() error {
 		switch scheme.Type {
@@ -66,7 +66,7 @@ func (p *parser) parseSecurityScheme(
 		case "mutualTLS":
 			return nil
 		case "oauth2":
-			return validateOAuthFlows(scopes, scheme.Flows)
+			return p.validateOAuthFlows(scopes, scheme.Flows)
 		case "openIdConnect":
 			if _, err := url.ParseRequestURI(scheme.OpenIDConnectURL); err != nil {
 				return errors.Wrap(err, `"openIdConnectUrl" MUST be in the form of a URL`)
@@ -82,15 +82,21 @@ func (p *parser) parseSecurityScheme(
 	return scheme, nil
 }
 
-func validateOAuthFlows(scopes []string, flows *ogen.OAuthFlows) error {
+func (p *parser) validateOAuthFlows(scopes []string, flows *ogen.OAuthFlows) (rerr error) {
 	if flows == nil {
 		return errors.New("oAuthFlows is empty or null")
 	}
+	defer func() {
+		rerr = p.wrapLocation(flows, rerr)
+	}()
 
-	check := func(flow *ogen.OAuthFlow, authURL, tokenURL bool) error {
+	check := func(flow *ogen.OAuthFlow, authURL, tokenURL bool) (rerr error) {
 		if flow == nil {
 			return nil
 		}
+		defer func() {
+			rerr = p.wrapLocation(flow, rerr)
+		}()
 
 		if tokenURL {
 			if _, err := url.ParseRequestURI(flow.TokenURL); err != nil {
diff --git a/openapi/parser/parser.go b/openapi/parser/parser.go
index 341d760f..4a29c5d7 100644
--- a/openapi/parser/parser.go
+++ b/openapi/parser/parser.go
@@ -66,6 +66,7 @@ func Parse(spec *ogen.Spec, s Settings) (*openapi.API, error) {
 				components: spec.Components.Schemas,
 				root:       jsonschema.NewRootResolver(spec.Raw),
 			},
+			Filename:   s.Filename,
 			DepthLimit: s.DepthLimit,
 			InferTypes: s.InferTypes,
 		}),
@@ -80,7 +81,7 @@ func Parse(spec *ogen.Spec, s Settings) (*openapi.API, error) {
 		return nil, errors.Wrap(err, "parse components")
 	}
 
-	if err := p.parseOps(); err != nil {
+	if err := p.parsePathItems(); err != nil {
 		return nil, errors.Wrap(err, "parse operations")
 	}
 
@@ -90,48 +91,58 @@ func Parse(spec *ogen.Spec, s Settings) (*openapi.API, error) {
 	}, nil
 }
 
-func (p *parser) parseOps() error {
+func (p *parser) parsePathItems() error {
 	operationIDs := make(map[string]struct{})
 	for path, item := range p.spec.Paths {
-		if item == nil {
-			return errors.Errorf("%s: pathItem object is empty or null", path)
-		}
-		if item.Ref != "" {
-			return errors.Errorf("%s: referenced pathItem not supported", path)
+		if err := p.parsePath(path, item, operationIDs); err != nil {
+			return errors.Wrapf(err, "path %q", path)
 		}
+	}
+	return nil
+}
 
-		itemParams, err := p.parseParams(item.Parameters)
-		if err != nil {
-			return errors.Wrapf(err, "%s: parameters", path)
-		}
+func (p *parser) parsePath(path string, item *ogen.PathItem, operationIDs map[string]struct{}) (rerr error) {
+	if item == nil {
+		return errors.New("pathItem object is empty or null")
+	}
+	defer func() {
+		rerr = p.wrapLocation(item, rerr)
+	}()
+	if item.Ref != "" {
+		return errors.New("referenced pathItem not supported")
+	}
 
-		if err := forEachOps(item, func(method string, op ogen.Operation) error {
-			if id := op.OperationID; id != "" {
-				if _, ok := operationIDs[id]; ok {
-					return errors.Errorf("duplicate operationId: %q", id)
-				}
-				operationIDs[id] = struct{}{}
-			}
+	itemParams, err := p.parseParams(item.Parameters)
+	if err != nil {
+		return errors.Wrap(err, "parameters")
+	}
 
-			parsedOp, err := p.parseOp(path, method, op, itemParams)
-			if err != nil {
-				if op.OperationID != "" {
-					return errors.Wrapf(err, "operation %q", op.OperationID)
-				}
-				return err
+	return forEachOps(item, func(method string, op ogen.Operation) error {
+		if id := op.OperationID; id != "" {
+			if _, ok := operationIDs[id]; ok {
+				return errors.Errorf("duplicate operationId: %q", id)
 			}
+			operationIDs[id] = struct{}{}
+		}
 
-			p.operations = append(p.operations, parsedOp)
-			return nil
-		}); err != nil {
-			return errors.Wrapf(err, "paths: %s", path)
+		parsedOp, err := p.parseOp(path, method, op, itemParams)
+		if err != nil {
+			if op.OperationID != "" {
+				return errors.Wrapf(err, "operation %q", op.OperationID)
+			}
+			return err
 		}
-	}
 
-	return nil
+		p.operations = append(p.operations, parsedOp)
+		return nil
+	})
 }
 
-func (p *parser) parseOp(path, httpMethod string, spec ogen.Operation, itemParams []*openapi.Parameter) (_ *openapi.Operation, err error) {
+func (p *parser) parseOp(
+	path, httpMethod string,
+	spec ogen.Operation,
+	itemParams []*openapi.Parameter,
+) (_ *openapi.Operation, err error) {
 	defer func() {
 		err = p.wrapLocation(&spec, err)
 	}()
diff --git a/parse_error.go b/parse_error.go
index 8bfe5955..0ae524cf 100644
--- a/parse_error.go
+++ b/parse_error.go
@@ -8,6 +8,9 @@ import (
 )
 
 func unmarshal(data []byte, out any) error {
+	var lines ogenjson.Lines
+	lines.Collect(data)
+
 	begin := int64(-1)
 	opts := json.UnmarshalOptions{
 		Unmarshalers: json.NewUnmarshalers(
@@ -15,18 +18,18 @@ func unmarshal(data []byte, out any) error {
 				begin = d.InputOffset()
 				return json.SkipFunc
 			}),
-			ogenjson.LocationUnmarshaler(data),
+			ogenjson.LocationUnmarshaler(lines),
 		),
 	}
 
 	if err := opts.Unmarshal(json.DecodeOptions{}, data, out); err != nil {
-		return wrapLineOffset(begin, data, err)
+		return wrapLineOffset(begin, lines, err)
 	}
 	return nil
 }
 
-func wrapLineOffset(offset int64, data []byte, err error) error {
-	line, column, ok := ogenjson.LineColumn(offset, data)
+func wrapLineOffset(offset int64, lines ogenjson.Lines, err error) error {
+	line, column, ok := lines.LineColumn(offset)
 	if !ok {
 		return err
 	}
diff --git a/spec.go b/spec.go
index c1ce1731..a07d4b6c 100644
--- a/spec.go
+++ b/spec.go
@@ -173,6 +173,8 @@ type Components struct {
 	SecuritySchemes map[string]*SecurityScheme `json:"securitySchemes,omitzero"`
 	// Links           map[string]Link            `json:"links"`
 	// Callbacks       map[string]Callback        `json:"callback"`
+
+	Locator `json:"-"`
 }
 
 // Init initializes all fields.
@@ -230,6 +232,8 @@ type PathItem struct {
 	Trace       *Operation   `json:"trace,omitzero"`
 	Servers     []Server     `json:"servers,omitzero"`
 	Parameters  []*Parameter `json:"parameters,omitzero"`
+
+	Locator `json:"-"`
 }
 
 // Operation describes a single API operation on a path.


Test patch:
diff --git a/json/location_test.go b/json/location_test.go
new file mode 100644
index 00000000..4502192f
--- /dev/null
+++ b/json/location_test.go
@@ -0,0 +1,38 @@
+package json
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestLines_Collect(t *testing.T) {
+	tests := []struct {
+		data  string
+		lines []int64
+	}{
+		{"", nil},
+		{"a", nil},
+		{"abcd", nil},
+		{"\n", []int64{0}},
+		{"a\n", []int64{1}},
+		{"a\n\n", []int64{1, 2}},
+		{"\n\n\n", []int64{0, 1, 2}},
+		{"a\nb\n", []int64{1, 3}},
+	}
+	for i, tt := range tests {
+		tt := tt
+		t.Run(fmt.Sprintf("Test%d", i+1), func(t *testing.T) {
+			a := assert.New(t)
+			data := []byte(tt.data)
+
+			var l Lines
+			l.Collect(data)
+			a.Equal(data, l.data)
+			for _, offset := range l.lines {
+				a.Equal(byte('\n'), data[offset])
+			}
+		})
+	}
+}
diff --git a/openapi/parser/parse_security_test.go b/openapi/parser/parse_security_test.go
index e20939a0..1d0536ec 100644
--- a/openapi/parser/parse_security_test.go
+++ b/openapi/parser/parse_security_test.go
@@ -73,7 +73,8 @@ func Test_validateOAuthFlows(t *testing.T) {
 		tt := tt
 		t.Run(fmt.Sprintf("Test%d", i+1), func(t *testing.T) {
 			a := require.New(t)
-			err := validateOAuthFlows(tt.scopes, tt.flows)
+			var p parser
+			err := p.validateOAuthFlows(tt.scopes, tt.flows)
 			if tt.wantErr {
 				a.Error(err)
 				return
diff --git a/openapi/parser/resolve_test.go b/openapi/parser/resolve_test.go
index 82afb868..a2f4520a 100644
--- a/openapi/parser/resolve_test.go
+++ b/openapi/parser/resolve_test.go
@@ -185,6 +185,10 @@ func TestExternalReference(t *testing.T) {
 			Type:     "string",
 			Examples: []json.RawValue{exampleValue},
 		}
+		localExample = &openapi.Example{
+			Ref:   "#/components/examples/LocalExample",
+			Value: exampleValue,
+		}
 		param = &openapi.Parameter{
 			Ref:     "#/components/parameters/LocalParameter",
 			Name:    "parameter",
@@ -228,10 +232,7 @@ func TestExternalReference(t *testing.T) {
 						Examples: []json.RawValue{exampleValue},
 					},
 					Examples: map[string]*openapi.Example{
-						"ref": {
-							Ref:   "foo.json#/components/examples/RemoteExample",
-							Value: exampleValue,
-						},
+						"ref": localExample,
 					},
 					Encoding: map[string]*openapi.Encoding{},
 				},
@@ -257,18 +258,21 @@ func TestExternalReference(t *testing.T) {
 			},
 		},
 		Components: &openapi.Components{
+			Schemas: map[string]*jsonschema.Schema{
+				"LocalSchema": schema,
+			},
+			Responses: map[string]*openapi.Response{
+				"LocalResponse": response,
+			},
 			Parameters: map[string]*openapi.Parameter{
 				"LocalParameter": param,
 			},
-			Schemas: map[string]*jsonschema.Schema{
-				"LocalSchema": schema,
+			Examples: map[string]*openapi.Example{
+				"LocalExample": localExample,
 			},
 			RequestBodies: map[string]*openapi.RequestBody{
 				"LocalRequestBody": requestBody,
 			},
-			Responses: map[string]*openapi.Response{
-				"LocalResponse": response,
-			},
 		},
 	}, spec)
 }
@@ -491,10 +495,11 @@ func TestComplicatedReference(t *testing.T) {
 			testPost,
 		},
 		Components: &openapi.Components{
-			Parameters:    map[string]*openapi.Parameter{},
 			Schemas:       map[string]*jsonschema.Schema{},
-			RequestBodies: map[string]*openapi.RequestBody{},
 			Responses:     map[string]*openapi.Response{},
+			Parameters:    map[string]*openapi.Parameter{},
+			Examples:      map[string]*openapi.Example{},
+			RequestBodies: map[string]*openapi.RequestBody{},
 		},
 	}, spec)
 }
diff --git a/parse_error_test.go b/parse_error_test.go
index fdfb6b7a..3a86f87f 100644
--- a/parse_error_test.go
+++ b/parse_error_test.go
@@ -30,11 +30,26 @@ func Test_wrapLineOffset(t *testing.T) {
     }
   }
 }`
+	ubool := func(input []byte) error {
+		var target bool
+		return unmarshal(input, &target)
+	}
 	tests := []struct {
 		input        string
 		target       func([]byte) error
 		line, column int
 	}{
+		{"{}", ubool, 1, 1},
+		{"{}\n", ubool, 1, 1},
+		{"\x20{}", ubool, 1, 2},
+		{"\x20{}\n", ubool, 1, 2},
+		{"\n{}", ubool, 2, 1},
+		{"\n{}\n", ubool, 2, 1},
+		{"\n\n{}", ubool, 3, 1},
+		{"\n\n{}\n", ubool, 3, 1},
+		{"\x20\n{}", ubool, 2, 1},
+		{"\x20\n{}\n", ubool, 2, 1},
+
 		{"{\n\t\"a\":1,\n\t\"b\":2\n}", func(input []byte) error {
 			var target struct {
 				A int  `json:"a"`
@@ -47,6 +62,7 @@ func Test_wrapLineOffset(t *testing.T) {
 			var target []int
 			return unmarshal(input, &target)
 		}, 3, 1},
+
 		{testdata, func(input []byte) error {
 			var target *Spec
 			return unmarshal(input, &target)
@@ -63,7 +79,7 @@ func Test_wrapLineOffset(t *testing.T) {
 
 			msg := err.Error()
 			prefix := fmt.Sprintf("line %d:%d", tt.line, tt.column)
-			a.Truef(strings.HasPrefix(msg, prefix), "prefix: %q, msg: %q", prefix, msg)
+			a.Truef(strings.HasPrefix(msg, prefix), "input: %q,\nprefix: %q,\nmsg: %q", tt.input, prefix, msg)
 		})
 	}
 }