Skip to content

Commit a7ee5e3

Browse files
Merge branch 'topic/gpr-tool-completion' into 'master'
Provide completion help for tool switches in GPR files See merge request eng/ide/ada_language_server!2158
2 parents 3e2e88c + 38743e2 commit a7ee5e3

File tree

27 files changed

+1646
-38
lines changed

27 files changed

+1646
-38
lines changed

gnat/lsp_server.gpr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ project LSP_Server is
4848
"../source/common",
4949
"../source/gpr",
5050
"../source/ada/generated",
51+
"../source/gpr/generated",
5152
"../source/memory");
5253

5354
for Object_Dir use "../.obj/server";

scripts/generate_tool_help_db.py

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
#!/usr/bin/env python3
2+
"""
3+
NOTE: this script has been developed with AI assistance.
4+
5+
Generate an Ada package with embedded tool switches database.
6+
7+
This script runs various tools with their help options and extracts the switches
8+
and their associated documentation into a structured JSON database, which is then
9+
embedded into an Ada package specification as string constants.
10+
"""
11+
12+
import argparse
13+
import json
14+
import re
15+
import subprocess
16+
import sys
17+
from typing import Dict, List, Optional
18+
19+
20+
class HelpParser:
21+
"""Base class for parsing tool help output."""
22+
23+
def parse(self, help_text: str) -> Dict[str, str]:
24+
"""Parse help text and return a dictionary of switches to documentation.
25+
26+
Args:
27+
help_text: The raw help output from the tool
28+
29+
Returns:
30+
Dictionary mapping switch names to their documentation
31+
"""
32+
raise NotImplementedError("Subclasses must implement parse()")
33+
34+
35+
class GnatHelpParser(HelpParser):
36+
"""Parser for GNAT compiler help output."""
37+
38+
def parse(self, help_text: str) -> Dict[str, str]:
39+
"""Parse GNAT help output format.
40+
41+
GNAT help has lines like:
42+
-switch Documentation text that may span
43+
multiple lines
44+
or:
45+
-switch Documentation text
46+
--long-switch More documentation
47+
"""
48+
switches = {}
49+
current_switch = None
50+
current_doc = []
51+
52+
lines = help_text.split("\n")
53+
54+
for line in lines:
55+
# Check if this line starts a new switch (starts with 1-5 spaces and a dash)
56+
# Handles both " -switch" and " -switch" and " --switch" patterns
57+
match = re.match(r"^ {1,5}(-+\S+(?:,\s*-+\S+)*)\s+(.*)$", line)
58+
if match:
59+
# Save previous switch if any
60+
if current_switch:
61+
switches[current_switch] = " ".join(current_doc).strip()
62+
63+
# Start new switch
64+
current_switch = match.group(1)
65+
current_doc = [match.group(2)] if match.group(2).strip() else []
66+
elif current_switch:
67+
# Continuation line - check if it's indented documentation
68+
stripped = line.strip()
69+
if stripped and not line.startswith(
70+
" ."
71+
): # Skip mode value descriptions
72+
# Only add if it doesn't look like a new section header
73+
if not re.match(r"^[A-Z][a-z].*:$", stripped):
74+
current_doc.append(stripped)
75+
elif stripped == "":
76+
# Empty line might indicate end of this switch's documentation
77+
pass
78+
79+
# Don't forget the last switch
80+
if current_switch:
81+
switches[current_switch] = " ".join(current_doc).strip()
82+
83+
return switches
84+
85+
86+
class GenericHelpParser(HelpParser):
87+
"""Generic parser for standard help output formats."""
88+
89+
def parse(self, help_text: str) -> Dict[str, str]:
90+
"""Parse generic help output.
91+
92+
Tries to identify lines that look like:
93+
-switch, --long-switch Documentation
94+
or:
95+
-switch Documentation
96+
"""
97+
switches = {}
98+
current_switch = None
99+
current_doc = []
100+
101+
lines = help_text.split("\n")
102+
103+
for line in lines:
104+
# Try to match common switch patterns
105+
match = re.match(r"^\s{0,4}(-+\S+(?:,\s*-+\S+)*)\s{2,}(.*)$", line)
106+
if match:
107+
# Save previous switch if any
108+
if current_switch:
109+
switches[current_switch] = " ".join(current_doc).strip()
110+
111+
# Start new switch (may be multiple comma-separated)
112+
switch_list = match.group(1)
113+
current_switch = switch_list
114+
current_doc = [match.group(2)]
115+
elif current_switch and line.startswith(" " * 6) and line.strip():
116+
# Continuation line with significant indentation
117+
current_doc.append(line.strip())
118+
elif line.strip() == "":
119+
# Empty line might end current switch
120+
if current_switch and current_doc:
121+
switches[current_switch] = " ".join(current_doc).strip()
122+
current_switch = None
123+
current_doc = []
124+
125+
# Don't forget the last switch
126+
if current_switch and current_doc:
127+
switches[current_switch] = " ".join(current_doc).strip()
128+
129+
return switches
130+
131+
132+
def run_tool_help(tool_command: str) -> Optional[str]:
133+
"""Run a tool with its help option and capture output.
134+
135+
Args:
136+
tool_command: The full command to run (e.g., "gnat --help-ada")
137+
138+
Returns:
139+
The help output as a string, or None if the command failed
140+
"""
141+
try:
142+
result = subprocess.run(
143+
tool_command.split(), capture_output=True, text=True, timeout=30
144+
)
145+
# Many tools output help to stderr, so combine both
146+
output = result.stdout + result.stderr
147+
return output if output.strip() else None
148+
except (
149+
subprocess.TimeoutExpired,
150+
subprocess.CalledProcessError,
151+
FileNotFoundError,
152+
) as e:
153+
print(f"Error running '{tool_command}': {e}", file=sys.stderr)
154+
return None
155+
156+
157+
def select_parser(tool_name: str) -> HelpParser:
158+
"""Select the appropriate parser for a given tool.
159+
160+
Args:
161+
tool_name: Name of the tool (extracted from command)
162+
163+
Returns:
164+
An appropriate HelpParser instance
165+
"""
166+
tool_name_lower = tool_name.lower()
167+
if tool_name_lower in ("gnat", "gnatprove"):
168+
return GnatHelpParser()
169+
else:
170+
return GenericHelpParser()
171+
172+
173+
def extract_tool_name(tool_command: str) -> str:
174+
"""Extract the tool name from a command string.
175+
176+
Args:
177+
tool_command: Full command like "gnat --help-ada"
178+
179+
Returns:
180+
Just the tool name, e.g., "gnat"
181+
"""
182+
return tool_command.split()[0]
183+
184+
185+
def escape_ada_string(s: str) -> str:
186+
"""Escape a string for use in an Ada string literal.
187+
188+
Args:
189+
s: The string to escape
190+
191+
Returns:
192+
The escaped string suitable for Ada string literals
193+
"""
194+
# In Ada, quotes are doubled to escape them
195+
return s.replace('"', '""')
196+
197+
198+
def split_string_for_ada(json_str: str, max_length: int = 1000) -> List[str]:
199+
"""Split a JSON string into chunks suitable for Ada string constants.
200+
201+
Args:
202+
json_str: The JSON string to split
203+
max_length: Maximum length of each chunk (to avoid Ada line length limits)
204+
205+
Returns:
206+
List of string chunks
207+
"""
208+
chunks = []
209+
i = 0
210+
while i < len(json_str):
211+
# Try to find a good breaking point (after comma or closing brace)
212+
end = min(i + max_length, len(json_str))
213+
if end < len(json_str):
214+
# Look back for a good break point
215+
for j in range(end, max(i, end - 100), -1):
216+
if json_str[j] in ",}]":
217+
end = j + 1
218+
break
219+
220+
chunks.append(json_str[i:end])
221+
i = end
222+
223+
return chunks
224+
225+
226+
def generate_ada_package(database: dict, output_dir: str):
227+
"""Generate an Ada package spec with the JSON database embedded.
228+
229+
Args:
230+
database: The tool database dictionary
231+
output_dir: Directory where to write the Ada package spec
232+
"""
233+
import os
234+
235+
# Convert database to compact JSON string
236+
json_str = json.dumps(database, ensure_ascii=False, separators=(",", ":"))
237+
238+
# Escape for Ada
239+
escaped_json = escape_ada_string(json_str)
240+
241+
# Split into manageable chunks
242+
chunks = split_string_for_ada(escaped_json, max_length=2000)
243+
244+
# Generate Ada package spec
245+
ada_code = []
246+
ada_code.append("-- Automatically generated, do not edit.")
247+
ada_code.append("")
248+
ada_code.append("pragma Style_Checks (Off);")
249+
ada_code.append("")
250+
ada_code.append("package LSP.GPR_Completions.Tools.Database is")
251+
ada_code.append("")
252+
253+
# Generate string constants for each chunk
254+
for i, chunk in enumerate(chunks, 1):
255+
ada_code.append(f' Db{i} : constant String := "{chunk}";')
256+
ada_code.append("")
257+
258+
# Generate the main concatenated constant
259+
db_parts = " & ".join(f"Db{i}" for i in range(1, len(chunks) + 1))
260+
ada_code.append(f" Db : constant String := {db_parts};")
261+
ada_code.append("")
262+
ada_code.append("end LSP.GPR_Completions.Tools.Database;")
263+
264+
# Write to file
265+
output_file = os.path.join(output_dir, "lsp-gpr_completions-tools-database.ads")
266+
with open(output_file, "w", encoding="utf-8") as f:
267+
f.write("\n".join(ada_code))
268+
269+
print(f"\nAda package written to: {output_file}")
270+
print(f"Total string chunks: {len(chunks)}")
271+
print(f"Total JSON size: {len(json_str)} characters")
272+
273+
274+
def generate_database(tool_configs: List[str], output_dir: str, pretty: bool = True):
275+
"""Generate the Ada package database from tool help outputs.
276+
277+
Args:
278+
tool_configs: List of tool commands (e.g., ["gnat --help-ada"])
279+
output_dir: Directory where to write the Ada package
280+
pretty: Unused (kept for compatibility)
281+
"""
282+
database = {}
283+
284+
for tool_command in tool_configs:
285+
print(f"Processing: {tool_command}")
286+
287+
# Extract tool name for the database key
288+
tool_name = extract_tool_name(tool_command)
289+
290+
# Run the tool and get help output
291+
help_text = run_tool_help(tool_command)
292+
if not help_text:
293+
print(
294+
f" Warning: No help output received for '{tool_command}'",
295+
file=sys.stderr,
296+
)
297+
continue
298+
299+
# Select appropriate parser
300+
parser = select_parser(tool_name)
301+
302+
# Parse the help text
303+
switches = parser.parse(help_text)
304+
305+
if not switches:
306+
print(
307+
f" Warning: No switches parsed from '{tool_command}'", file=sys.stderr
308+
)
309+
continue
310+
311+
print(f" Found {len(switches)} switches")
312+
313+
# Store in database (use tool name as key)
314+
database[tool_name] = {
315+
"command": tool_command,
316+
"switches": switches,
317+
}
318+
319+
# Generate Ada package
320+
generate_ada_package(database, output_dir)
321+
322+
print(f"Total tools processed: {len(database)}")
323+
324+
325+
def main():
326+
"""Main entry point."""
327+
parser = argparse.ArgumentParser(
328+
description="Generate Ada package with embedded tool switches database",
329+
formatter_class=argparse.RawDescriptionHelpFormatter,
330+
epilog="""
331+
Examples:
332+
%(prog)s -o source/gpr/generated "gnat --help-ada"
333+
%(prog)s -o source/gpr/generated "gnat --help-ada" "gnatprove --help"
334+
%(prog)s --default -o source/gpr/generated
335+
""",
336+
)
337+
338+
parser.add_argument(
339+
"tools", nargs="*", help='Tool commands to process (e.g., "gnat --help-ada")'
340+
)
341+
342+
parser.add_argument(
343+
"-o",
344+
"--output",
345+
default="source/gpr/generated",
346+
help="Output directory for Ada package (default: source/gpr/generated)",
347+
)
348+
349+
parser.add_argument(
350+
"--compact",
351+
action="store_true",
352+
help="Unused (kept for compatibility)",
353+
)
354+
355+
parser.add_argument(
356+
"--default",
357+
action="store_true",
358+
help="Process default GNAT tools (gnat --help-ada, gnatprove --help)",
359+
)
360+
361+
args = parser.parse_args()
362+
363+
# Use default tools if --default is specified or no tools provided
364+
if args.default or not args.tools:
365+
default_tools = ["gnat --help-ada", "gnatprove --help"]
366+
tools_to_process = default_tools
367+
else:
368+
tools_to_process = args.tools
369+
370+
generate_database(tools_to_process, args.output, pretty=not args.compact)
371+
372+
373+
if __name__ == "__main__":
374+
main()

0 commit comments

Comments
 (0)