Skip to content

Commit 9f82eff

Browse files
committedMay 17, 2022
feature: parse documentation comments
1 parent 6e478d2 commit 9f82eff

File tree

9 files changed

+401
-18
lines changed

9 files changed

+401
-18
lines changed
 
+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using OpenScadGraphEditor.Nodes;
4+
5+
namespace OpenScadGraphEditor.Library.External
6+
{
7+
/// <summary>
8+
/// This holds the contents of a documentation comment.
9+
/// </summary>
10+
public class DocumentationComment
11+
{
12+
private class ParameterDescription
13+
{
14+
public readonly string Name;
15+
public readonly string Description;
16+
public readonly PortType TypeHint;
17+
18+
public ParameterDescription(string name = "", string description = "", PortType typeHint = PortType.None)
19+
{
20+
Name = name;
21+
Description = description;
22+
TypeHint = typeHint;
23+
}
24+
}
25+
26+
public string Summary { private get; set; } = "";
27+
public string ReturnValueDescription { private get; set; } = "";
28+
public PortType ReturnValueTypeHint { private get; set; } = PortType.None;
29+
30+
private readonly List<ParameterDescription> _parameters = new List<ParameterDescription>();
31+
32+
33+
/// <summary>
34+
/// Applies the contents of the documentation comment to the given invokable description.
35+
/// </summary>
36+
public void ApplyTo(InvokableDescription description)
37+
{
38+
description.Description = Summary;
39+
foreach (var parameter in description.Parameters)
40+
{
41+
var parameterDescription = _parameters.LastOrDefault(it => it.Name == parameter.Name);
42+
if (parameterDescription == null)
43+
{
44+
// no matching parameter description found in comment, skip over to the next one.
45+
continue;
46+
}
47+
48+
if (parameterDescription.TypeHint != PortType.None)
49+
{
50+
// type hint from the comment always wins over any inferred type hint.
51+
parameter.TypeHint = parameterDescription.TypeHint;
52+
}
53+
parameter.Description = parameterDescription.Description;
54+
}
55+
56+
if (description is FunctionDescription functionDescription)
57+
{
58+
if (ReturnValueTypeHint != PortType.None)
59+
{
60+
// also a return type hint from the comment always wins over any inferred type hint.
61+
functionDescription.ReturnTypeHint = ReturnValueTypeHint;
62+
}
63+
functionDescription.ReturnValueDescription = ReturnValueDescription;
64+
}
65+
}
66+
67+
public void AddParameter(string name, string description = "", PortType typeHint = PortType.None)
68+
{
69+
_parameters.Add(new ParameterDescription(name, description, typeHint));
70+
}
71+
}
72+
}

‎Library/External/ExternalFileParser.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public static void Parse(string text, ExternalReference externalReference)
1515
var parser = new OpenScadParser(tokenStream);
1616

1717
var root = parser.scadFile();
18-
var visitor = new OpenScadVisitor(externalReference);
18+
var visitor = new OpenScadVisitor(tokenStream, externalReference);
1919
visitor.Visit(root);
2020

2121
externalReference.IsLoaded = true;

‎Library/External/OpenScadVisitor.cs

+283-10
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
1+
using System;
12
using System.Linq;
3+
using System.Text;
4+
using System.Text.RegularExpressions;
25
using Antlr4.Runtime;
36
using Antlr4.Runtime.Tree;
47
using Godot;
8+
using GodotExt;
9+
using OpenScadGraphEditor.Nodes;
510

611
namespace OpenScadGraphEditor.Library.External
712
{
813
public class OpenScadVisitor : OpenScadParserBaseVisitor<object>
914
{
15+
private readonly CommonTokenStream _commonTokenStream;
1016
private readonly ExternalReference _externalReference;
1117
private readonly string _sourceFileHash;
1218

1319

14-
public OpenScadVisitor(ExternalReference externalReference)
20+
public OpenScadVisitor(CommonTokenStream commonTokenStream, ExternalReference externalReference)
1521
{
22+
_commonTokenStream = commonTokenStream;
1623
_externalReference = externalReference;
1724
_sourceFileHash = _externalReference.IncludePath.SHA256Text();
1825
}
@@ -61,27 +68,288 @@ public override object VisitFunctionDeclaration(OpenScadParser.FunctionDeclarati
6168
// first check if this function is not inside of any module
6269
if (IsNotInsideModule(context))
6370
{
64-
// for now we treat any parameter that is from an external function as PortType.ANY
65-
// same goes for the return type.
66-
71+
72+
6773
var functionName = context.identifier().IDENTIFIER().GetText();
74+
// return type of a function is always inferred as PortType.ANY unless overwritten by
75+
// the documentation comment
6876
var builder = FunctionBuilder.NewFunction(functionName, MakeId("function", functionName));
6977

7078
// now find all the parameters
7179
var parameterDeclarations = context.parameterList().parameterDeclaration();
7280
foreach (var parameter in parameterDeclarations)
7381
{
7482
var name = parameter.identifier().IDENTIFIER().GetText();
75-
builder.WithParameter(name);
83+
// try to infer the parameter type.
84+
var portType = InferParameterType(parameter);
85+
builder.WithParameter(name, portType);
7686
}
7787

78-
_externalReference.Functions.Add(builder.Build());
88+
var comment = ParseDocumentationComment(context.FUNCTION());
89+
90+
var functionDescription = builder.Build();
91+
// apply any documentation comment
92+
comment.ApplyTo(functionDescription);
93+
94+
_externalReference.Functions.Add(functionDescription);
7995
}
8096

8197
// and walk the rest of the tree
8298
return base.VisitFunctionDeclaration(context);
8399
}
84100

101+
private PortType InferParameterType(OpenScadParser.ParameterDeclarationContext parameterDeclarationContext)
102+
{
103+
var expression = parameterDeclarationContext.expression();
104+
if (expression == null)
105+
{
106+
return PortType.Any;
107+
}
108+
109+
OpenScadParser.ExpressionContext StripParentheses(OpenScadParser.ExpressionContext outer)
110+
{
111+
while(outer.parenthesizedExpression() != null)
112+
{
113+
outer = outer.parenthesizedExpression().expression();
114+
}
115+
116+
return outer;
117+
}
118+
119+
expression = StripParentheses(expression);
120+
121+
// is it a simple expression?
122+
var simpleExpression = expression.simpleExpression();
123+
if (simpleExpression?.NUMBER() != null)
124+
{
125+
return PortType.Number;
126+
}
127+
128+
if (simpleExpression?.STRING() != null)
129+
{
130+
return PortType.String;
131+
}
132+
133+
if (simpleExpression?.BOOLEAN() != null)
134+
{
135+
return PortType.Boolean;
136+
}
137+
138+
// is it a vector expression?
139+
var vectorExpression = expression.vectorExpression();
140+
if (vectorExpression != null)
141+
{
142+
// [ VectorInner, VectorInner, VectorInner ]
143+
// count how many vectorInner we have
144+
var vectorContents = vectorExpression.children.OfType<OpenScadParser.VectorInnerContext>().ToList();
145+
if (vectorContents.Count > 3)
146+
{
147+
// we don't care what is inside, this is an array
148+
return PortType.Array;
149+
}
150+
151+
// now check that all vector contents are ultimately numbers.
152+
foreach (var vectorInner in vectorContents)
153+
{
154+
// [ VectorInner { ParenthesizedVectorInner { ... expression ... } } ]
155+
var innerExpression = vectorInner;
156+
while (innerExpression.parenthesizedVectorInner() != null)
157+
{
158+
innerExpression = vectorInner.parenthesizedVectorInner().vectorInner();
159+
}
160+
161+
// now we should have all parenthesized vectorInner removed.
162+
var innermostExpression = StripParentheses(innerExpression.expression());
163+
if (innermostExpression.simpleExpression()?.NUMBER() == null)
164+
{
165+
// if the expression is anything but a number, we infer as "Array"
166+
return PortType.Array;
167+
}
168+
}
169+
170+
// if we get here, we have a vector of numbers
171+
if (vectorContents.Count == 2)
172+
{
173+
return PortType.Vector2;
174+
}
175+
176+
return PortType.Vector3;
177+
}
178+
179+
// anything else, we don't recognize -> Any
180+
return PortType.Any;
181+
}
182+
183+
private DocumentationComment ParseDocumentationComment(ISyntaxTree invokableDeclarationStart)
184+
{
185+
186+
var hiddenTokensToLeft = _commonTokenStream
187+
.GetHiddenTokensToLeft(invokableDeclarationStart.SourceInterval.a, 2 /* channel 2 == comments */);
188+
189+
// that is a really bad API, a list should never return null but apparently from the source code
190+
// it does.
191+
if (hiddenTokensToLeft == null)
192+
{
193+
// return an empty comment
194+
return new DocumentationComment();
195+
}
196+
197+
string CleanUpCommentText(string text)
198+
{
199+
// remove any starting /** and whitespace or newlines
200+
text = text.TrimStart('/', '*', ' ', '\n', '\r', '\t');
201+
// remove any ending */ and whitespace or newlines
202+
text = text.TrimEnd('/', '*', ' ', '\n', '\r', '\t');
203+
204+
// for every line in the text, remove leading * and all whitespace before the *.
205+
// do not remove whitespace after the *, because that is intended indentation.
206+
var lines = text.Split('\n');
207+
var result = new StringBuilder();
208+
foreach (var line in lines)
209+
{
210+
var trimmedLine = line.TrimStart('*', ' ', '\t');
211+
// we deliberately do not use AppendLine here as we always use \n as line separator internally.
212+
result.Append(trimmedLine).Append("\n");
213+
}
214+
215+
// remove the last newline
216+
result.Length--;
217+
218+
return result.ToString();
219+
}
220+
221+
// check the returned list backwards and find the first documentation comment (e.g. the one that is closest
222+
// to the invokable declaration)
223+
foreach(var token in hiddenTokensToLeft.Reverse())
224+
{
225+
if (token.Type != OpenScadParser.BLOCK_COMMENT || !token.Text.StartsWith("/**"))
226+
{
227+
continue; // not a documentation comment
228+
}
229+
230+
// a documentation comment starts with the text, and then there may be @param and @return tags
231+
// in any order. The text after each tag belongs in full to the preceding tag. So we need to
232+
// split the text into the tags and then parse the tags separately.
233+
234+
const int paramTag = 1;
235+
const int returnTag = 2;
236+
// we declare the regexes here to avoid the overhead of creating them every time we need them
237+
var paramRegex = new Regex(@"@param\s+(?<name>\w+)\s*(?<type>\[\w+\])?\s+(?<description>.*)", RegexOptions.Singleline);
238+
var returnRegex = new Regex(@"@return\s+(?<type>\[\w+\])?\s+(?<description>.*)", RegexOptions.Singleline);
239+
240+
241+
// Helper function that finds the next tag in the text and its type.
242+
bool HasNextTag(string text, int startIndex, out int resultIndex, out int tagType)
243+
{
244+
resultIndex = text.IndexOf("@param", startIndex, StringComparison.Ordinal);
245+
if (resultIndex == -1)
246+
{
247+
resultIndex = text.IndexOf("@return", startIndex, StringComparison.Ordinal);
248+
if (resultIndex == -1)
249+
{
250+
tagType = default;
251+
return false;
252+
}
253+
tagType = returnTag;
254+
return true;
255+
}
256+
257+
tagType = paramTag;
258+
return true;
259+
}
260+
261+
// helper function that converts a type string into a PortType
262+
PortType GetPortType(string typeString)
263+
{
264+
if (typeString == null )
265+
{
266+
return PortType.None;
267+
}
268+
269+
switch (typeString)
270+
{
271+
case "[number]":
272+
return PortType.Number;
273+
case "[string]":
274+
return PortType.String;
275+
case "[any]":
276+
return PortType.Any;
277+
case "[boolean]":
278+
return PortType.Boolean;
279+
case "[vector2]":
280+
return PortType.Vector2;
281+
case "[vector3]":
282+
return PortType.Vector3;
283+
case "[array]":
284+
return PortType.Array;
285+
}
286+
287+
// anything else or something we don't recognize
288+
return PortType.None;
289+
}
290+
291+
if (!HasNextTag(token.Text, 0, out var nextIndex, out var nextTagType)) {
292+
// there is no next tag, so we can use the whole text
293+
return new DocumentationComment {Summary = CleanUpCommentText(token.Text)};
294+
}
295+
296+
// everything until now is the summary
297+
var result = new DocumentationComment
298+
{
299+
Summary = CleanUpCommentText(token.Text.Substring(0, nextIndex))
300+
};
301+
302+
do
303+
{
304+
var hasNext = HasNextTag(token.Text, nextIndex + 1, out var endIndex, out var followingTagType);
305+
if (!hasNext)
306+
{
307+
endIndex = token.Text.Length - 1;
308+
}
309+
310+
// now extract the tag text
311+
var tagText = token.Text.Substring(nextIndex, endIndex - nextIndex);
312+
313+
// check the tag type and try to parse it.
314+
switch (nextTagType)
315+
{
316+
case paramTag:
317+
var paramMatch = paramRegex.Match(tagText);
318+
if (paramMatch.Success)
319+
{
320+
result.AddParameter(
321+
paramMatch.Groups["name"]?.Value ?? "",
322+
CleanUpCommentText(paramMatch.Groups["description"]?.Value ?? ""),
323+
GetPortType(paramMatch.Groups["type"]?.Value)
324+
);
325+
}
326+
// if we can't parse it properly, ignore it.
327+
break;
328+
case returnTag:
329+
var returnMatch = returnRegex.Match(tagText);
330+
if (returnMatch.Success)
331+
{
332+
result.ReturnValueTypeHint = GetPortType(returnMatch.Groups["type"]?.Value);
333+
result.ReturnValueDescription = CleanUpCommentText(returnMatch.Groups["description"]?.Value ?? "");
334+
}
335+
// if we can't parse it properly, ignore it.
336+
break;
337+
}
338+
339+
if (!hasNext)
340+
{
341+
return result;
342+
}
343+
344+
nextIndex = endIndex;
345+
nextTagType = followingTagType;
346+
} while (true);
347+
}
348+
349+
// no documentation comment found at all, return an empty one
350+
return new DocumentationComment();
351+
}
352+
85353

86354
public override object VisitModuleDeclaration(OpenScadParser.ModuleDeclarationContext context)
87355
{
@@ -96,15 +364,20 @@ public override object VisitModuleDeclaration(OpenScadParser.ModuleDeclarationCo
96364
foreach (var parameter in parameterDeclarations)
97365
{
98366
var name = parameter.identifier().IDENTIFIER().GetText();
99-
builder.WithParameter(name);
367+
var portType = InferParameterType(parameter);
368+
builder.WithParameter(name, portType);
100369
}
101-
// finally check if this module supports children
370+
// check if this module supports children
102371
if (SupportsChildren(context))
103372
{
104373
builder.WithChildren();
105374
}
106-
_externalReference.Modules.Add(builder.Build());
107-
375+
376+
var moduleDescription = builder.Build();
377+
// parse any documentation comment
378+
var documentationComment = ParseDocumentationComment(context.MODULE());
379+
documentationComment.ApplyTo(moduleDescription);
380+
_externalReference.Modules.Add(moduleDescription);
108381
}
109382

110383
// and walk the rest of the tree

‎Nodes/PortType.cs

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ namespace OpenScadGraphEditor.Nodes
44
{
55
public enum PortType
66
{
7+
// indicates the absence of a port type.
8+
None = 0,
79
// do not change the numbers, otherwise saved graphs will break!
810
Flow = 1,
911
Boolean = 2,

‎OpenScadLexer.g4

+2-2
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,11 @@ NUMBER
7474

7575

7676
BLOCK_COMMENT
77-
: '/*' .*? '*/' -> skip
77+
: '/*' .*? '*/' -> channel(2)
7878
;
7979

8080
LINE_COMMENT
81-
: '//' ~[\r\n]* -> skip
81+
: '//' ~[\r\n]* -> channel(2)
8282
;
8383

8484
mode FILE_IMPORT_MODE;

‎TestData/comment_parsing.scad

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Import test that verifies that documentation comments override inferred values.
3+
*
4+
* @param boolean [boolean] a boolean
5+
* @param number [number] a number
6+
* @param string [string] a string
7+
* @param vector2 [vector2] a vector2
8+
* @param vector3 [vector3] a vector3
9+
* @param array [array] an array
10+
* @param any [any] any type
11+
* @return [number] a number
12+
*/
13+
function doc_overrides(number,boolean,string,vector2,vector3,array,any=5) = 10;
14+
15+
16+
function inferred(number=1, boolean=true, string="hello", vector2=[1,2], vector3=[1,(2),3], array=[1,2,3,4], any) = 10;

‎Utils/StringExt.cs

+16
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,22 @@ public static string AsBlock(this string input)
4242
return input.Length == 0 ? ";" : $" {{\n{input.Indent()}\n}}\n";
4343
}
4444

45+
public static string Trimmed(this string input, int maxLength)
46+
{
47+
if (input.Length <= maxLength)
48+
{
49+
return input;
50+
}
51+
52+
if (maxLength < 3)
53+
{
54+
// maxLenght is super short, return maxLength dots
55+
return new string('.', maxLength);
56+
}
57+
58+
return input.Substring(0, maxLength - 3) + "...";
59+
}
60+
4561
public static string UniqueStableVariableName(this string id, int index)
4662
{
4763

‎Widgets/DocumentationDialog/DocumentationDialog.tscn

-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
[ext_resource path="res://Widgets/DocumentationDialog/DocumentationDialog.cs" type="Script" id=1]
44

55
[node name="DocumentationDialog" type="WindowDialog"]
6-
visible = true
76
anchor_left = 0.5
87
anchor_top = 0.5
98
anchor_right = 0.5

‎Widgets/HelpDialog/HelpDialog.cs

+9-4
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,18 @@ public void Open(IScadGraph graph, ScadNode node)
4949
_widget.BindTo(graph, node);
5050
_widget.HintTooltip = "";
5151

52-
_titleLabel.Text = node.NodeTitle;
53-
_descriptionLabel.Text = node.NodeDescription.OrDefault("<no documentation available>");
52+
_titleLabel.Text = node.NodeTitle.Trimmed(50);
53+
_descriptionLabel.Text = node.NodeDescription
54+
.OrDefault("<no documentation available>")
55+
.Trimmed(500);
56+
5457

5558
_leftContainer.GetChildNodes<Label>().ForAll(it => it.RemoveAndFree());
5659
_rightContainer.GetChildNodes<Label>().ForAll(it => it.RemoveAndFree());
5760

5861
for (var i = 0; i < node.InputPortCount; i++)
5962
{
60-
var helpText = node.GetPortDocumentation(PortId.Input(i)).OrDefault("<no documentation available>");
63+
var helpText = node.GetPortDocumentation(PortId.Input(i)).OrDefault("<no documentation available>").Trimmed(200);
6164

6265
var label = new Label();
6366
label.Text = helpText;
@@ -68,7 +71,9 @@ public void Open(IScadGraph graph, ScadNode node)
6871

6972
for (var i = 0; i < node.OutputPortCount; i++)
7073
{
71-
var helpText = node.GetPortDocumentation(PortId.Output(i)).OrDefault("<no documentation available>");
74+
var helpText = node.GetPortDocumentation(PortId.Output(i))
75+
.OrDefault("<no documentation available>")
76+
.Trimmed(200);
7277

7378
var label = new Label();
7479
label.Text = helpText;

0 commit comments

Comments
 (0)
Please sign in to comment.