Skip to content

Commit 6af7b6e

Browse files
arikonclaude
andcommitted
feat: add document symbol provider to ALS
Adds Document Symbol support to the Ansible Language Server, enabling outline view and breadcrumbs in editors. Supports plays, tasks, blocks, roles, and task sections (handlers, pre_tasks, post_tasks). Falls back to flat SymbolInformation when the client lacks hierarchical support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ae6a026 commit 6af7b6e

File tree

8 files changed

+598
-0
lines changed

8 files changed

+598
-0
lines changed

packages/ansible-language-server/src/ansibleLanguageService.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313
doCompletionResolve,
1414
} from "@src/providers/completionProvider.js";
1515
import { getDefinition } from "@src/providers/definitionProvider.js";
16+
import {
17+
getDocumentSymbols,
18+
flattenSymbols,
19+
} from "@src/providers/documentSymbolProvider.js";
1620
import { doHover } from "@src/providers/hoverProvider.js";
1721
import {
1822
doSemanticTokens,
@@ -79,6 +83,7 @@ export class AnsibleLanguageService {
7983
resolveProvider: true,
8084
},
8185
definitionProvider: true,
86+
documentSymbolProvider: true,
8287
workspace: {},
8388
},
8489
};
@@ -327,6 +332,26 @@ export class AnsibleLanguageService {
327332
return null;
328333
});
329334

335+
this.connection.onDocumentSymbol(async (params) => {
336+
try {
337+
const document = this.documents.get(params.textDocument.uri);
338+
if (document) {
339+
const symbols = getDocumentSymbols(document);
340+
if (!symbols) return null;
341+
const supportsHierarchy =
342+
this.workspaceManager.clientCapabilities.textDocument
343+
?.documentSymbol?.hierarchicalDocumentSymbolSupport ?? false;
344+
if (supportsHierarchy) {
345+
return symbols;
346+
}
347+
return flattenSymbols(symbols, params.textDocument.uri);
348+
}
349+
} catch (error) {
350+
this.handleError(error, "onDocumentSymbol");
351+
}
352+
return null;
353+
});
354+
330355
// Custom actions that are performed on receiving special notifications from the client
331356
// Resync ansible inventory service by clearing the cached items
332357
this.connection.onNotification("resync/ansible-inventory", async () => {
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import {
2+
DocumentSymbol,
3+
SymbolInformation,
4+
SymbolKind,
5+
} from "vscode-languageserver";
6+
import { TextDocument } from "vscode-languageserver-textdocument";
7+
import { isMap, isScalar, isSeq, Node, Scalar, YAMLMap, YAMLSeq } from "yaml";
8+
import { playExclusiveKeywords, isTaskKeyword } from "@src/utils/ansible.js";
9+
import {
10+
getOrigRange,
11+
getYamlMapKeys,
12+
parseAllDocuments,
13+
} from "@src/utils/yaml.js";
14+
import { toLspRange } from "@src/utils/misc.js";
15+
16+
const taskSectionKeys =
17+
/^(tasks|pre_tasks|post_tasks|handlers|block|rescue|always)$/;
18+
19+
export function getDocumentSymbols(
20+
document: TextDocument,
21+
): DocumentSymbol[] | null {
22+
const yamlDocs = parseAllDocuments(document.getText());
23+
if (yamlDocs.length === 0) return null;
24+
25+
const doc = yamlDocs[0];
26+
if (!doc.contents || !isSeq(doc.contents)) return null;
27+
28+
return processRootSequence(doc.contents, document);
29+
}
30+
31+
export function flattenSymbols(
32+
symbols: DocumentSymbol[],
33+
uri: string,
34+
): SymbolInformation[] {
35+
const result: SymbolInformation[] = [];
36+
for (const s of symbols) {
37+
result.push({
38+
name: s.name,
39+
kind: s.kind,
40+
location: { uri, range: s.range },
41+
});
42+
if (s.children) {
43+
result.push(...flattenSymbols(s.children, uri));
44+
}
45+
}
46+
return result;
47+
}
48+
49+
function processRootSequence(
50+
seq: YAMLSeq,
51+
document: TextDocument,
52+
): DocumentSymbol[] {
53+
const symbols: DocumentSymbol[] = [];
54+
for (const item of seq.items) {
55+
if (!isMap(item)) continue;
56+
const keys = getYamlMapKeys(item);
57+
const isPlay = keys.some((k) => playExclusiveKeywords.has(k));
58+
if (isPlay) {
59+
const symbol = createPlaySymbol(item, document);
60+
if (symbol) symbols.push(symbol);
61+
} else {
62+
const symbol = createTaskSymbol(item, document);
63+
if (symbol) symbols.push(symbol);
64+
}
65+
}
66+
return symbols.length > 0 ? symbols : (null as unknown as DocumentSymbol[]);
67+
}
68+
69+
function createPlaySymbol(
70+
map: YAMLMap,
71+
document: TextDocument,
72+
): DocumentSymbol | null {
73+
const range = nodeToRange(map, document);
74+
if (!range) return null;
75+
76+
const name = getScalarValue(map, "name") ?? getPlayFallbackName(map);
77+
const selectionRange = getKeyRange(map, "name", document) ?? range;
78+
79+
const children: DocumentSymbol[] = [];
80+
81+
for (const pair of map.items) {
82+
if (!isScalar(pair.key)) continue;
83+
const key = String(pair.key.value);
84+
85+
if (key === "roles" && isSeq(pair.value)) {
86+
const sectionSymbol = createSectionSymbol(
87+
key,
88+
pair.key,
89+
pair.value,
90+
document,
91+
processRoles,
92+
);
93+
if (sectionSymbol) children.push(sectionSymbol);
94+
} else if (taskSectionKeys.test(key) && isSeq(pair.value)) {
95+
const sectionSymbol = createSectionSymbol(
96+
key,
97+
pair.key,
98+
pair.value,
99+
document,
100+
processTaskList,
101+
);
102+
if (sectionSymbol) children.push(sectionSymbol);
103+
}
104+
}
105+
106+
return DocumentSymbol.create(
107+
name,
108+
undefined,
109+
SymbolKind.Struct,
110+
range,
111+
selectionRange,
112+
children.length > 0 ? children : undefined,
113+
);
114+
}
115+
116+
function createSectionSymbol(
117+
name: string,
118+
keyNode: Scalar,
119+
valueNode: YAMLSeq,
120+
document: TextDocument,
121+
processor: (seq: YAMLSeq, document: TextDocument) => DocumentSymbol[],
122+
): DocumentSymbol | null {
123+
const valueRange = nodeToRange(valueNode, document);
124+
const keyRange = nodeToRange(keyNode, document);
125+
if (!valueRange || !keyRange) return null;
126+
127+
// Section range spans from key to end of value
128+
const sectionRange = { start: keyRange.start, end: valueRange.end };
129+
const sectionChildren = processor(valueNode, document);
130+
131+
return DocumentSymbol.create(
132+
name,
133+
undefined,
134+
SymbolKind.Field,
135+
sectionRange,
136+
keyRange,
137+
sectionChildren.length > 0 ? sectionChildren : undefined,
138+
);
139+
}
140+
141+
function processTaskList(
142+
seq: YAMLSeq,
143+
document: TextDocument,
144+
): DocumentSymbol[] {
145+
const symbols: DocumentSymbol[] = [];
146+
for (const item of seq.items) {
147+
if (!isMap(item)) continue;
148+
const symbol = createTaskSymbol(item, document);
149+
if (symbol) symbols.push(symbol);
150+
}
151+
return symbols;
152+
}
153+
154+
function createTaskSymbol(
155+
map: YAMLMap,
156+
document: TextDocument,
157+
): DocumentSymbol | null {
158+
const range = nodeToRange(map, document);
159+
if (!range) return null;
160+
161+
const keys = getYamlMapKeys(map);
162+
const isBlock = keys.includes("block");
163+
164+
if (isBlock) {
165+
return createBlockSymbol(map, document, range);
166+
}
167+
168+
const name = getScalarValue(map, "name") ?? getTaskModuleName(map) ?? "Task";
169+
const selectionRange = getKeyRange(map, "name", document) ?? range;
170+
171+
return DocumentSymbol.create(
172+
name,
173+
undefined,
174+
SymbolKind.Function,
175+
range,
176+
selectionRange,
177+
);
178+
}
179+
180+
function createBlockSymbol(
181+
map: YAMLMap,
182+
document: TextDocument,
183+
range: ReturnType<typeof toLspRange>,
184+
): DocumentSymbol {
185+
const blockName = getScalarValue(map, "name");
186+
const name = blockName ? `block: ${blockName}` : "block";
187+
const selectionRange =
188+
getKeyRange(map, "name", document) ??
189+
getKeyRange(map, "block", document) ??
190+
range;
191+
192+
const children: DocumentSymbol[] = [];
193+
for (const pair of map.items) {
194+
if (!isScalar(pair.key)) continue;
195+
const key = String(pair.key.value);
196+
if (/^(block|rescue|always)$/.test(key) && isSeq(pair.value)) {
197+
const sectionSymbol = createSectionSymbol(
198+
key,
199+
pair.key,
200+
pair.value,
201+
document,
202+
processTaskList,
203+
);
204+
if (sectionSymbol) children.push(sectionSymbol);
205+
}
206+
}
207+
208+
return DocumentSymbol.create(
209+
name,
210+
undefined,
211+
SymbolKind.Namespace,
212+
range,
213+
selectionRange,
214+
children.length > 0 ? children : undefined,
215+
);
216+
}
217+
218+
function processRoles(seq: YAMLSeq, document: TextDocument): DocumentSymbol[] {
219+
const symbols: DocumentSymbol[] = [];
220+
for (const item of seq.items) {
221+
if (isScalar(item)) {
222+
// Simple role reference: `- role_name`
223+
const range = nodeToRange(item, document);
224+
if (range) {
225+
symbols.push(
226+
DocumentSymbol.create(
227+
String(item.value),
228+
undefined,
229+
SymbolKind.Package,
230+
range,
231+
range,
232+
),
233+
);
234+
}
235+
} else if (isMap(item)) {
236+
// Role with params: `- role: role_name`
237+
const range = nodeToRange(item, document);
238+
if (!range) continue;
239+
const roleName =
240+
getScalarValue(item, "role") ?? getScalarValue(item, "name") ?? "Role";
241+
const selectionRange =
242+
getKeyRange(item, "role", document) ??
243+
getKeyRange(item, "name", document) ??
244+
range;
245+
symbols.push(
246+
DocumentSymbol.create(
247+
roleName,
248+
undefined,
249+
SymbolKind.Package,
250+
range,
251+
selectionRange,
252+
),
253+
);
254+
}
255+
}
256+
return symbols;
257+
}
258+
259+
function getTaskModuleName(map: YAMLMap): string | null {
260+
for (const pair of map.items) {
261+
if (!isScalar(pair.key)) continue;
262+
const key = String(pair.key.value);
263+
if (!isTaskKeyword(key)) {
264+
return key;
265+
}
266+
}
267+
return null;
268+
}
269+
270+
function getScalarValue(map: YAMLMap, key: string): string | null {
271+
for (const pair of map.items) {
272+
if (isScalar(pair.key) && pair.key.value === key && isScalar(pair.value)) {
273+
return String(pair.value.value);
274+
}
275+
}
276+
return null;
277+
}
278+
279+
function getPlayFallbackName(map: YAMLMap): string {
280+
const hosts = getScalarValue(map, "hosts");
281+
return hosts ? `Play [hosts: ${hosts}]` : "Play";
282+
}
283+
284+
function nodeToRange(node: Node, document: TextDocument) {
285+
const range = getOrigRange(node);
286+
if (!range) return null;
287+
return toLspRange(range, document);
288+
}
289+
290+
function getKeyRange(map: YAMLMap, key: string, document: TextDocument) {
291+
for (const pair of map.items) {
292+
if (isScalar(pair.key) && pair.key.value === key) {
293+
// Use the value node range for selection if available
294+
if (isScalar(pair.value)) {
295+
return nodeToRange(pair.value, document);
296+
}
297+
return nodeToRange(pair.key, document);
298+
}
299+
}
300+
return null;
301+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
- name: Play with blocks
3+
hosts: all
4+
tasks:
5+
- name: Main block
6+
block:
7+
- name: Task in block
8+
ansible.builtin.debug:
9+
msg: "in block"
10+
11+
- name: Another task in block
12+
ansible.builtin.debug:
13+
msg: "also in block"
14+
15+
rescue:
16+
- name: Handle error
17+
ansible.builtin.debug:
18+
msg: "rescued"
19+
20+
always:
21+
- name: Always run
22+
ansible.builtin.debug:
23+
msg: "always"

packages/ansible-language-server/test/fixtures/documentSymbol/empty.yml

Whitespace-only changes.

0 commit comments

Comments
 (0)