Skip to content

Commit 7b82ff1

Browse files
committed
add locations and pointer
1 parent 931c925 commit 7b82ff1

19 files changed

Lines changed: 221 additions & 26 deletions

File tree

docs/@v2/commands/map.md

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Each node has the following fields:
2727
| summary | string | Optional. The node's `summary` field, or its `description` truncated at a word boundary (about 200 characters). |
2828
| method | string | Optional. HTTP method; present on OpenAPI `Operation` nodes only. |
2929
| path | string | Optional. URL path; present on OpenAPI `Operation` nodes under `paths` only. |
30-
| source | object | Optional. Original `{ file, pointer }` location of the node; present when `--source-locations` is used. |
30+
| source | object | Optional. Original `{ file, pointer, startLine, endLine }` location of the node; present when `--source-locations` is used. |
3131
| nodes | array | Child nodes. |
3232

3333
The tree stops at operations, channels, and named components;
@@ -44,6 +44,7 @@ use `--source-locations` when you need the exact file and location of each node.
4444
```bash
4545
redocly map <api>
4646
redocly map <api> [--format=<option>] [--source-locations] [--config=<path>]
47+
redocly map <api> --pointer=<json-pointer>
4748
redocly map --version
4849
```
4950

@@ -56,7 +57,8 @@ redocly map --version
5657
| --format | string | Format for the output.<br />**Possible values:** `stylish`, `json`. Default value is `stylish`. |
5758
| --help | boolean | Show help. |
5859
| --lint-config | string | Specify the severity level for the configuration file. <br/> **Possible values:** `warn`, `error`, `off`. Default value is `warn`. |
59-
| --source-locations | boolean | Include the original source file and pointer for each node. Useful for multi-file descriptions. |
60+
| --pointer | string | Print the content at the given JSON pointer instead of the map. YAML by default; JSON with `--format=json`. |
61+
| --source-locations | boolean | Include the original source file, pointer, and line range for each node. Useful for multi-file descriptions. |
6062
| --version | boolean | Show version number. |
6163

6264
## Examples
@@ -136,11 +138,35 @@ redocly map openapi.yaml --format=json --source-locations
136138
"pointer": "#/paths/~1menu",
137139
"source": {
138140
"file": "paths/menu.yaml",
139-
"pointer": "#/"
141+
"pointer": "#/",
142+
"startLine": 1,
143+
"endLine": 3
140144
},
141145
"nodes": []
142146
}
143147
```
144148

145149
The `pointer` stays canonical to the logical document,
146-
while `source` tells you which file the node actually lives in.
150+
while `source` tells you which file and lines the node actually lives in —
151+
so any tool that can read a file range can retrieve the node's content.
152+
153+
### Retrieve the content of a node
154+
155+
Use `--pointer` with a canonical pointer from the map to print that node's content
156+
instead of the map itself:
157+
158+
```bash
159+
redocly map openapi.yaml --pointer=#/paths/~1menu
160+
```
161+
162+
```yaml
163+
get:
164+
operationId: listMenuItems
165+
summary: List all menu items
166+
```
167+
168+
The node itself is resolved (a path item referenced from another file prints that file's content),
169+
and `$ref`s inside the printed content are kept as-is —
170+
follow them with further `--pointer` calls.
171+
Any pointer into the logical document works, including paths deeper than the map's nodes,
172+
for example `--pointer=#/paths/~1menu/get/summary`.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Pointer retrieval keeps `$ref`s intact
2+
3+
`redocly map --pointer=<json-pointer>` closes the retrieval half of the map's index-navigate-retrieve loop:
4+
it prints the logical content at any canonical pointer, resolving the addressed node itself
5+
(a path item that is a file `$ref` prints the target file's content)
6+
but leaving `$ref`s inside the printed content untouched.
7+
8+
We chose not to inline the subtree:
9+
inlining duplicates shared schemas across fetches, needs cycle guards for recursive schemas,
10+
and can silently explode output size.
11+
Keeping refs is honest and cycle-proof — the consumer follows them with further `--pointer` calls,
12+
guided by the map.
13+
14+
Source locations additionally carry `startLine`/`endLine`
15+
(computed from the same YAML AST positions the lint codeframes use),
16+
so consumers with plain file tools can retrieve node content without any JSON-pointer tooling.

packages/cli/src/commands/map/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import {
33
buildApiMap,
44
detectSpec,
55
getMajorSpecVersion,
6+
logger,
7+
resolveApiMapPointer,
8+
stringifyYaml,
69
type Document,
710
type OutputFormat,
811
} from '@redocly/openapi-core';
@@ -19,6 +22,7 @@ export type MapArgv = {
1922
api?: string;
2023
format: OutputFormat;
2124
'source-locations'?: boolean;
25+
pointer?: string;
2226
} & VerifyConfigOptions;
2327

2428
export async function handleMap({ argv, config, collectSpecData }: CommandArgs<MapArgv>) {
@@ -40,6 +44,24 @@ export async function handleMap({ argv, config, collectSpecData }: CommandArgs<M
4044
);
4145
}
4246

47+
if (argv.pointer) {
48+
const content = await resolveApiMapPointer({
49+
document,
50+
config,
51+
externalRefResolver,
52+
pointer: argv.pointer,
53+
});
54+
if (content === undefined) {
55+
exitWithError(
56+
`No content found at pointer ${argv.pointer}. Run the command without --pointer to see the available nodes.`
57+
);
58+
}
59+
logger.output(
60+
argv.format === 'json' ? JSON.stringify(content, null, 2) : stringifyYaml(content)
61+
);
62+
return;
63+
}
64+
4365
const apiMap = await buildApiMap({
4466
document,
4567
config,

packages/cli/src/commands/map/print-map/stylish.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import * as colors from 'colorette';
33

44
export function printApiMapStylish(node: ApiMapNode, depth = 0) {
55
const indent = ' '.repeat(depth);
6-
const source = node.source ? colors.dim(` (${node.source.file}${node.source.pointer})`) : '';
6+
const source = node.source
7+
? colors.dim(` (${node.source.file}:${node.source.startLine}-${node.source.endLine})`)
8+
: '';
79
logger.output(
810
`${indent}${node.title} ${colors.dim(node.kind)} ${colors.cyan(node.pointer)}${source}\n`
911
);

packages/cli/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ yargs(hideBin(process.argv))
103103
type: 'boolean',
104104
default: false,
105105
},
106+
pointer: {
107+
description: 'Print the content at the given JSON pointer instead of the map.',
108+
type: 'string',
109+
},
106110
}),
107111
(argv) => {
108112
commandWrapper(handleMap)(argv);

packages/core/src/api-map/__tests__/build-api-map.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -455,15 +455,15 @@ describe('buildApiMap', () => {
455455
title: '/menu',
456456
kind: 'PathItem',
457457
pointer: '#/paths/~1menu',
458-
source: { file: 'paths/menu.yaml', pointer: '#/' },
458+
source: { file: 'paths/menu.yaml', pointer: '#/', startLine: 1, endLine: 2 },
459459
nodes: [
460460
{
461461
title: 'listMenuItems',
462462
kind: 'Operation',
463463
pointer: '#/paths/~1menu/get',
464464
method: 'get',
465465
path: '/menu',
466-
source: { file: 'paths/menu.yaml', pointer: '#/get' },
466+
source: { file: 'paths/menu.yaml', pointer: '#/get', startLine: 2, endLine: 2 },
467467
nodes: [],
468468
},
469469
],
@@ -472,15 +472,15 @@ describe('buildApiMap', () => {
472472
title: '/menu-archive',
473473
kind: 'PathItem',
474474
pointer: '#/paths/~1menu-archive',
475-
source: { file: 'paths/menu.yaml', pointer: '#/' },
475+
source: { file: 'paths/menu.yaml', pointer: '#/', startLine: 1, endLine: 2 },
476476
nodes: [
477477
{
478478
title: 'listMenuItems',
479479
kind: 'Operation',
480480
pointer: '#/paths/~1menu-archive/get',
481481
method: 'get',
482482
path: '/menu-archive',
483-
source: { file: 'paths/menu.yaml', pointer: '#/get' },
483+
source: { file: 'paths/menu.yaml', pointer: '#/get', startLine: 2, endLine: 2 },
484484
nodes: [],
485485
},
486486
],
@@ -494,7 +494,7 @@ describe('buildApiMap', () => {
494494
kind: 'Schema',
495495
pointer: '#/components/schemas/MenuItem',
496496
summary: 'A menu item.',
497-
source: { file: 'components/menu-item.yaml', pointer: '#/' },
497+
source: { file: 'components/menu-item.yaml', pointer: '#/', startLine: 1, endLine: 2 },
498498
nodes: [],
499499
},
500500
]);

packages/core/src/api-map/build-api-map.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Config } from '../config/index.js';
22
import { detectSpec } from '../detect-spec.js';
33
import { getTypes } from '../oas-types.js';
4+
import { Location } from '../ref-utils.js';
45
import { BaseResolver, resolveDocument, type Document } from '../resolve.js';
56
import { normalizeTypes } from '../types/index.js';
67
import {
@@ -13,6 +14,7 @@ import {
1314
} from '../visitors.js';
1415
import { walkDocument, type WalkContext } from '../walk.js';
1516
import { ApiMapAsync2, ApiMapAsync3 } from './async.js';
17+
import { makeSourceLocation } from './hooks.js';
1618
import { ApiMapOAS2, ApiMapOAS3 } from './oas.js';
1719
import type { ApiMapNode } from './types.js';
1820

@@ -32,7 +34,7 @@ export async function buildApiMap({
3234
title: 'API',
3335
kind: 'Root',
3436
pointer: '#/',
35-
...(sourceLocations && { source: { file: document.source.absoluteRef, pointer: '#/' } }),
37+
...(sourceLocations && { source: makeSourceLocation(new Location(document.source, '#/')) }),
3638
nodes: [],
3739
};
3840

packages/core/src/api-map/hooks.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
1-
import { escapePointerFragment } from '../ref-utils.js';
1+
import { getLineColLocation } from '../format/codeframes.js';
2+
import { escapePointerFragment, type Location } from '../ref-utils.js';
23
import { isPlainObject } from '../utils/is-plain-object.js';
34
import type { UserContext } from '../walk.js';
4-
import type { ApiMapNode, ApiMapOptions } from './types.js';
5+
import type { ApiMapNode, ApiMapNodeSource, ApiMapOptions } from './types.js';
6+
7+
export function makeSourceLocation(location: Location): ApiMapNodeSource {
8+
const { start, end } = getLineColLocation(location);
9+
return {
10+
file: location.source.absoluteRef,
11+
pointer: location.pointer,
12+
startLine: start?.line ?? 1,
13+
endLine: end?.line ?? start?.line ?? 1,
14+
};
15+
}
516

617
const SUMMARY_MAX_LENGTH = 200;
718

@@ -38,9 +49,7 @@ export function createApiMapHooks(root: ApiMapNode, opts: ApiMapOptions) {
3849
...(init.summary && { summary: init.summary }),
3950
...(init.method && { method: init.method }),
4051
...(init.path && { path: init.path }),
41-
...(opts.sourceLocations && {
42-
source: { file: ctx.location.source.absoluteRef, pointer: ctx.location.pointer },
43-
}),
52+
...(opts.sourceLocations && { source: makeSourceLocation(ctx.location) }),
4453
nodes: [],
4554
};
4655
parentNode.nodes.push(node);
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { Config } from '../config/index.js';
2+
import { detectSpec } from '../detect-spec.js';
3+
import { getTypes } from '../oas-types.js';
4+
import { isRef, parsePointer } from '../ref-utils.js';
5+
import { BaseResolver, resolveDocument, type Document } from '../resolve.js';
6+
import { normalizeTypes } from '../types/index.js';
7+
import { isPlainObject } from '../utils/is-plain-object.js';
8+
import { makeRefId } from '../utils/make-ref-id.js';
9+
10+
export async function resolveApiMapPointer({
11+
document,
12+
config,
13+
externalRefResolver = new BaseResolver(config.resolve),
14+
pointer,
15+
}: {
16+
document: Document;
17+
config: Config;
18+
externalRefResolver?: BaseResolver;
19+
pointer: string;
20+
}): Promise<unknown> {
21+
const specVersion = detectSpec(document.parsed);
22+
const types = normalizeTypes(config.extendTypes(getTypes(specVersion), specVersion), config);
23+
const resolvedRefMap = await resolveDocument({
24+
rootDocument: document,
25+
rootType: types.Root,
26+
externalRefResolver,
27+
});
28+
29+
let current: unknown = document.parsed;
30+
let fromFile = document.source.absoluteRef;
31+
32+
const followRefs = () => {
33+
while (isRef(current)) {
34+
const resolvedRef = resolvedRefMap.get(makeRefId(fromFile, current.$ref));
35+
if (!resolvedRef?.resolved) return false;
36+
current = resolvedRef.node;
37+
fromFile = resolvedRef.document.source.absoluteRef;
38+
}
39+
return true;
40+
};
41+
42+
for (const segment of parsePointer(pointer.replace(/^#\//, ''))) {
43+
if (!followRefs()) return undefined;
44+
if (Array.isArray(current)) {
45+
current = current[Number(segment)];
46+
} else if (isPlainObject(current)) {
47+
current = current[segment];
48+
} else {
49+
return undefined;
50+
}
51+
if (current === undefined) return undefined;
52+
}
53+
54+
return followRefs() ? current : undefined;
55+
}

packages/core/src/api-map/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export type ApiMapNodeSource = {
22
file: string;
33
pointer: string;
4+
startLine: number;
5+
endLine: number;
46
};
57

68
export type ApiMapNode = {

0 commit comments

Comments
 (0)