package com.semmle.js.parser; import com.semmle.js.ast.Comment; import com.semmle.js.ast.Position; import com.semmle.js.ast.SourceLocation; import com.semmle.js.ast.jsdoc.AllLiteral; import com.semmle.js.ast.jsdoc.ArrayType; import com.semmle.js.ast.jsdoc.FieldType; import com.semmle.js.ast.jsdoc.FunctionType; import com.semmle.js.ast.jsdoc.JSDocComment; import com.semmle.js.ast.jsdoc.JSDocTag; import com.semmle.js.ast.jsdoc.JSDocTypeExpression; import com.semmle.js.ast.jsdoc.NameExpression; import com.semmle.js.ast.jsdoc.NonNullableType; import com.semmle.js.ast.jsdoc.NullLiteral; import com.semmle.js.ast.jsdoc.NullableLiteral; import com.semmle.js.ast.jsdoc.NullableType; import com.semmle.js.ast.jsdoc.OptionalType; import com.semmle.js.ast.jsdoc.ParameterType; import com.semmle.js.ast.jsdoc.RecordType; import com.semmle.js.ast.jsdoc.RestType; import com.semmle.js.ast.jsdoc.TypeApplication; import com.semmle.js.ast.jsdoc.UndefinedLiteral; import com.semmle.js.ast.jsdoc.UnionType; import com.semmle.js.ast.jsdoc.VoidLiteral; import com.semmle.util.data.Pair; import com.semmle.util.exception.Exceptions; import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; /** A Java port of doctrine. */ public class JSDocParser { private String source; /** Parse the given string as a JSDoc comment. */ public JSDocComment parse(Comment comment) { source = comment.getText().substring(1); JSDocTagParser p = new JSDocTagParser(); Pair> r = p.new TagParser(null).parse(source); List tags = new ArrayList<>(); for (JSDocTagParser.Tag tag : r.snd()) { String title = tag.title; String description = tag.description; String name = tag.name; int startLine = tag.startLine; int startColumn = tag.startColumn; JSDocTypeExpression jsdocType = tag.type; int realStartLine = comment.getLoc().getStart().getLine() + startLine; int realStartColumn = (startLine == 0 ? comment.getLoc().getStart().getColumn() + 3 : 0) + startColumn; SourceLocation loc = new SourceLocation( source, new Position(realStartLine, realStartColumn, -1), new Position(realStartLine, realStartColumn + 1 + title.length(), -1)); tags.add(new JSDocTag(loc, title, description, name, jsdocType, tag.errors)); } return new JSDocComment(comment, r.fst(), tags); } /** Specification of Doctrine AST types for JSDoc type expressions. */ private static final Map, List> spec = new LinkedHashMap, List>(); static { spec.put(AllLiteral.class, Arrays.asList()); spec.put(ArrayType.class, Arrays.asList("elements")); spec.put(FieldType.class, Arrays.asList("key", "value")); spec.put(FunctionType.class, Arrays.asList("this", "new", "params", "result")); spec.put(NameExpression.class, Arrays.asList("name")); spec.put(NonNullableType.class, Arrays.asList("expression", "prefix")); spec.put(NullableLiteral.class, Arrays.asList()); spec.put(NullLiteral.class, Arrays.asList()); spec.put(NullableType.class, Arrays.asList("expression", "prefix")); spec.put(OptionalType.class, Arrays.asList("expression")); spec.put(ParameterType.class, Arrays.asList("name", "expression")); spec.put(RecordType.class, Arrays.asList("fields")); spec.put(RestType.class, Arrays.asList("expression")); spec.put(TypeApplication.class, Arrays.asList("expression", "applications")); spec.put(UndefinedLiteral.class, Arrays.asList()); spec.put(UnionType.class, Arrays.asList("elements")); spec.put(VoidLiteral.class, Arrays.asList()); } private static String sliceSource(String source, int index, int last) { if (index >= source.length()) return ""; if (last > source.length()) last = source.length(); return source.substring(index, last); } private static boolean isLineTerminator(int ch) { return ch == '\n' || ch == '\r' || ch == '\u2028' || ch == '\u2029'; } private static boolean isWhiteSpace(char ch) { return Character.isWhitespace(ch) && !isLineTerminator(ch) || ch == '\u00a0'; } private static boolean isDecimalDigit(char ch) { return "0123456789".indexOf(ch) >= 0; } private static boolean isHexDigit(char ch) { return "0123456789abcdefABCDEF".indexOf(ch) >= 0; } private static boolean isOctalDigit(char ch) { return "01234567".indexOf(ch) >= 0; } private static boolean isASCIIAlphanumeric(char ch) { return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9'); } private static boolean isIdentifierStart(char ch) { return (ch == '\\') || Character.isJavaIdentifierStart(ch); } private static boolean isIdentifierPart(char ch) { return (ch == '\\') || Character.isJavaIdentifierPart(ch); } private static boolean isTypeName(char ch) { return "><(){}[],:*|?!=".indexOf(ch) == -1 && !isWhiteSpace(ch) && !isLineTerminator(ch); } private static boolean isParamTitle(String title) { return title.equals("param") || title.equals("argument") || title.equals("arg"); } private static boolean isProperty(String title) { return title.equals("property") || title.equals("prop"); } private static boolean isNameParameterRequired(String title) { return isParamTitle(title) || isProperty(title) || title.equals("alias") || title.equals("this") || title.equals("mixes") || title.equals("requires"); } private static boolean isAllowedName(String title) { return isNameParameterRequired(title) || title.equals("const") || title.equals("constant"); } private static boolean isAllowedNested(String title) { return isProperty(title) || isParamTitle(title); } private static boolean isTypeParameterRequired(String title) { return isParamTitle(title) || title.equals("define") || title.equals("enum") || title.equals("implements") || title.equals("return") || title.equals("this") || title.equals("type") || title.equals("typedef") || title.equals("returns") || isProperty(title); } // Consider deprecation instead using 'isTypeParameterRequired' and 'Rules' declaration to pick // when a type is optional/required // This would require changes to 'parseType' private static boolean isAllowedType(String title) { return isTypeParameterRequired(title) || title.equals("throws") || title.equals("const") || title.equals("constant") || title.equals("namespace") || title.equals("member") || title.equals("var") || title.equals("module") || title.equals("constructor") || title.equals("class") || title.equals("extends") || title.equals("augments") || title.equals("public") || title.equals("private") || title.equals("protected"); } private static T throwError(String message) throws ParseError { throw new ParseError(message, null); } private enum Token { ILLEGAL, // ILLEGAL DOT, // . DOT_LT, // .< REST, // ... LT, // < GT, // > LPAREN, // ( RPAREN, // ) LBRACE, // { RBRACE, // } LBRACK, // [ RBRACK, // ] COMMA, // , COLON, // : STAR, // * PIPE, // | QUESTION, // ? BANG, // ! EQUAL, // = NAME, // name token STRING, // string NUMBER, // number EOF }; private class TypeExpressionParser { String source; int length; int previous, index; Token token; Object value; private class Context { int _previous, _index; Token _token; Object _value; Context(int previous, int index, Token token, Object value) { this._previous = previous; this._index = index; this._token = token; this._value = value; } void restore() { previous = this._previous; index = this._index; token = this._token; value = this._value; } } Context save() { return new Context(previous, index, token, value); } private SourceLocation loc() { return new SourceLocation(pos()); } private Position pos() { return new Position(1, index + 1, index); } private T finishNode(T node) { SourceLocation loc = node.getLoc(); Position end = pos(); loc.setSource(inputSubstring(loc.getStart().getOffset(), end.getOffset())); loc.setEnd(end); return node; } private String inputSubstring(int start, int end) { if (start >= source.length()) return ""; if (end > source.length()) end = source.length(); return source.substring(start, end); } private int advance() { if (index >= source.length()) return -1; return source.charAt(index++); } private String scanHexEscape(char prefix) { int i, len, ch, code = 0; len = (prefix == 'u') ? 4 : 2; for (i = 0; i < len; ++i) { if (index < length && isHexDigit(source.charAt(index))) { ch = advance(); code = code * 16 + "0123456789abcdef".indexOf(Character.toLowerCase(ch)); } else { return ""; } } return new String(Character.toChars(code)); } private Token scanString() throws ParseError { StringBuilder str = new StringBuilder(); int quote, ch, code, restore; // TODO review removal octal = false String unescaped; quote = source.charAt(index); ++index; while (index < length) { ch = advance(); if (ch == quote) { quote = -1; break; } else if (ch == '\\') { ch = advance(); if (!isLineTerminator(ch)) { switch (ch) { case 'n': str.append('\n'); break; case 'r': str.append('\r'); break; case 't': str.append('\t'); break; case 'u': case 'x': restore = index; unescaped = scanHexEscape((char) ch); if (!unescaped.isEmpty()) { str.append(unescaped); } else { index = restore; str.append((char) ch); } break; case 'b': str.append('\b'); break; case 'f': str.append('\f'); break; case 'v': str.append('\u000b'); break; default: if (isOctalDigit((char) ch)) { code = "01234567".indexOf(ch); // \0 is not octal escape sequence // Deprecating unused code. TODO review removal // if (code != 0) { // octal = true; // } if (index < length && isOctalDigit(source.charAt(index))) { // TODO Review Removal octal = true; code = code * 8 + "01234567".indexOf(advance()); // 3 digits are only allowed when string starts // with 0, 1, 2, 3 if ("0123".indexOf(ch) >= 0 && index < length && isOctalDigit(source.charAt(index))) { code = code * 8 + "01234567".indexOf(advance()); } } str.append(Character.toChars(code)); } else { str.append((char) ch); } break; } } else { if (ch == '\r' && index < length && source.charAt(index) == '\n') { ++index; } } } else if (isLineTerminator(ch)) { break; } else { str.append((char) ch); } } if (quote != -1) { throwError("unexpected quote"); } value = str.toString(); return Token.STRING; } private Token scanNumber() throws ParseError { StringBuilder number = new StringBuilder(); boolean isFloat = false; char ch = '\0'; if (ch != '.') { int next = advance(); number.append((char) next); ch = index < length ? source.charAt(index) : '\0'; if (next == '0') { if (ch == 'x' || ch == 'X') { number.append((char) advance()); while (index < length) { ch = source.charAt(index); if (!isHexDigit(ch)) { break; } number.append((char) advance()); } if (number.length() <= 2) { // only 0x throwError("unexpected token"); } if (index < length) { ch = source.charAt(index); if (isIdentifierStart(ch)) { throwError("unexpected token"); } } try { value = Integer.parseInt(number.toString(), 16); } catch (NumberFormatException nfe) { Exceptions.ignore(nfe, "Precise exception content is unimportant"); throwError("Invalid hexadecimal constant " + number); } return Token.NUMBER; } if (isOctalDigit(ch)) { number.append((char) advance()); while (index < length) { ch = source.charAt(index); if (!isOctalDigit(ch)) { break; } number.append((char) advance()); } if (index < length) { ch = source.charAt(index); if (isIdentifierStart(ch) || isDecimalDigit(ch)) { throwError("unexpected token"); } } try { value = Integer.parseInt(number.toString(), 8); } catch (NumberFormatException nfe) { Exceptions.ignore(nfe, "Precise exception content is unimportant"); throwError("Invalid octal constant " + number); } return Token.NUMBER; } if (isDecimalDigit(ch)) { throwError("unexpected token"); } } while (index < length) { ch = source.charAt(index); if (!isDecimalDigit(ch)) { break; } number.append((char) advance()); } } if (ch == '.') { isFloat = true; number.append((char) advance()); while (index < length) { ch = source.charAt(index); if (!isDecimalDigit(ch)) { break; } number.append((char) advance()); } } if (ch == 'e' || ch == 'E') { isFloat = true; number.append((char) advance()); ch = index < length ? source.charAt(index) : '\0'; if (ch == '+' || ch == '-') { number.append((char) advance()); } ch = index < length ? source.charAt(index) : '\0'; if (isDecimalDigit(ch)) { number.append((char) advance()); while (index < length) { ch = source.charAt(index); if (!isDecimalDigit(ch)) { break; } number.append((char) advance()); } } else { throwError("unexpected token"); } } if (index < length) { ch = source.charAt(index); if (isIdentifierStart(ch)) { throwError("unexpected token"); } } String num = number.toString(); try { if (isFloat) value = Double.parseDouble(num); else value = Integer.parseInt(num); } catch (NumberFormatException nfe) { Exceptions.ignore(nfe, "Precise exception content is unimportant"); throwError("Invalid numeric literal " + num); } return Token.NUMBER; } private Token scanTypeName() { char ch, ch2; value = new String(Character.toChars(advance())); while (index < length && isTypeName(source.charAt(index))) { ch = source.charAt(index); if (ch == '.') { if ((index + 1) < length) { ch2 = source.charAt(index + 1); if (ch2 == '<') { break; } } } value += new String(Character.toChars(advance())); } return Token.NAME; } private Token next() throws ParseError { char ch; previous = index; while (index < length && isWhiteSpace(source.charAt(index))) { advance(); } if (index >= length) { token = Token.EOF; return token; } ch = source.charAt(index); switch (ch) { case '"': token = scanString(); return token; case ':': advance(); token = Token.COLON; return token; case ',': advance(); token = Token.COMMA; return token; case '(': advance(); token = Token.LPAREN; return token; case ')': advance(); token = Token.RPAREN; return token; case '[': advance(); token = Token.LBRACK; return token; case ']': advance(); token = Token.RBRACK; return token; case '{': advance(); token = Token.LBRACE; return token; case '}': advance(); token = Token.RBRACE; return token; case '.': advance(); if (index < length) { ch = source.charAt(index); if (ch == '<') { advance(); token = Token.DOT_LT; return token; } if (ch == '.' && index + 1 < length && source.charAt(index + 1) == '.') { advance(); advance(); token = Token.REST; return token; } if (isDecimalDigit(ch)) { token = scanNumber(); return token; } } token = Token.DOT; return token; case '<': advance(); token = Token.LT; return token; case '>': advance(); token = Token.GT; return token; case '*': advance(); token = Token.STAR; return token; case '|': advance(); token = Token.PIPE; return token; case '?': advance(); token = Token.QUESTION; return token; case '!': advance(); token = Token.BANG; return token; case '=': advance(); token = Token.EQUAL; return token; default: if (isDecimalDigit(ch)) { token = scanNumber(); return token; } // type string permits following case, // // namespace.module.MyClass // // this reduced 1 token TK_NAME if (isTypeName(ch)) { token = scanTypeName(); return token; } token = Token.ILLEGAL; return token; } } private void consume(Token target, String text) throws ParseError { if (token != target) throwError(text == null ? "consumed token not matched" : text); next(); } private void consume(Token target) throws ParseError { consume(target, null); } private void expect(Token target) throws ParseError { if (token != target) { throwError("unexpected token"); } next(); } // UnionType := '(' TypeUnionList ')' // // TypeUnionList := // <> // | NonemptyTypeUnionList // // NonemptyTypeUnionList := // TypeExpression // | TypeExpression '|' NonemptyTypeUnionList private JSDocTypeExpression parseUnionType() throws ParseError { SourceLocation loc = loc(); List elements = new ArrayList<>(); consume(Token.LPAREN, "UnionType should start with ("); if (token != Token.RPAREN) { while (true) { elements.add(parseTypeExpression()); if (token == Token.RPAREN) { break; } expect(Token.PIPE); } } consume(Token.RPAREN, "UnionType should end with )"); return finishNode(new UnionType(loc, elements)); } // ArrayType := '[' ElementTypeList ']' // // ElementTypeList := // <> // | TypeExpression // | '...' TypeExpression // | TypeExpression ',' ElementTypeList private JSDocTypeExpression parseArrayType() throws ParseError { SourceLocation loc = loc(); List elements = new ArrayList<>(); consume(Token.LBRACK, "ArrayType should start with ["); while (token != Token.RBRACK) { loc = loc(); if (token == Token.REST) { consume(Token.REST); elements.add(finishNode(new RestType(loc, parseTypeExpression()))); break; } else { elements.add(parseTypeExpression()); } if (token != Token.RBRACK) { expect(Token.COMMA); } } expect(Token.RBRACK); return finishNode(new ArrayType(loc, elements)); } private String parseFieldName() throws ParseError { Object v = value; if (token == Token.NAME || token == Token.STRING) { next(); return v.toString(); } if (token == Token.NUMBER) { consume(Token.NUMBER); return v.toString(); } return throwError("unexpected token"); } // FieldType := // FieldName // | FieldName ':' TypeExpression // // FieldName := // NameExpression // | StringLiteral // | NumberLiteral // | ReservedIdentifier private FieldType parseFieldType() throws ParseError { String key; SourceLocation loc = loc(); key = parseFieldName(); if (token == Token.COLON) { consume(Token.COLON); return finishNode(new FieldType(loc, key, parseTypeExpression())); } return finishNode(new FieldType(loc, key, null)); } // RecordType := '{' FieldTypeList '}' // // FieldTypeList := // <> // | FieldType // | FieldType ',' FieldTypeList private JSDocTypeExpression parseRecordType() throws ParseError { List fields = new ArrayList<>(); SourceLocation loc = loc(); consume(Token.LBRACE, "RecordType should start with {"); if (token == Token.COMMA) { consume(Token.COMMA); } else { while (token != Token.RBRACE) { fields.add(parseFieldType()); if (token != Token.RBRACE) { expect(Token.COMMA); } } } expect(Token.RBRACE); return finishNode(new RecordType(loc, fields)); } private JSDocTypeExpression parseNameExpression() throws ParseError { Object name = value; SourceLocation loc = loc(); expect(Token.NAME); return finishNode(new NameExpression(loc, name.toString())); } // TypeExpressionList := // TopLevelTypeExpression // | TopLevelTypeExpression ',' TypeExpressionList private List parseTypeExpressionList() throws ParseError { List elements = new ArrayList<>(); elements.add(parseTop()); while (token == Token.COMMA) { consume(Token.COMMA); elements.add(parseTop()); } return elements; } // TypeName := // NameExpression // | NameExpression TypeApplication // // TypeApplication := // '.<' TypeExpressionList '>' // | '<' TypeExpressionList '>' // this is extension of doctrine private JSDocTypeExpression parseTypeName() throws ParseError { JSDocTypeExpression expr; List applications; SourceLocation loc = loc(); expr = parseNameExpression(); if (token == Token.DOT_LT || token == Token.LT) { next(); applications = parseTypeExpressionList(); expect(Token.GT); return finishNode(new TypeApplication(loc, expr, applications)); } return expr; } // ResultType := // <> // | ':' void // | ':' TypeExpression // // BNF is above // but, we remove <> pattern, so token is always TypeToken::COLON private JSDocTypeExpression parseResultType() throws ParseError { consume(Token.COLON, "ResultType should start with :"); SourceLocation loc = loc(); if (token == Token.NAME && value.equals("void")) { consume(Token.NAME); return finishNode(new VoidLiteral(loc)); } return parseTypeExpression(); } // ParametersType := // RestParameterType // | NonRestParametersType // | NonRestParametersType ',' RestParameterType // // RestParameterType := // '...' // '...' Identifier // // NonRestParametersType := // ParameterType ',' NonRestParametersType // | ParameterType // | OptionalParametersType // // OptionalParametersType := // OptionalParameterType // | OptionalParameterType, OptionalParametersType // // OptionalParameterType := ParameterType= // // ParameterType := TypeExpression | Identifier ':' TypeExpression // // Identifier is "new" or "this" private List parseParametersType() throws ParseError { List params = new ArrayList<>(); boolean normal = true; JSDocTypeExpression expr; boolean rest = false; while (token != Token.RPAREN) { if (token == Token.REST) { // RestParameterType consume(Token.REST); rest = true; } SourceLocation loc = loc(); expr = parseTypeExpression(); if (expr instanceof NameExpression && token == Token.COLON) { // Identifier ':' TypeExpression consume(Token.COLON); expr = finishNode( new ParameterType(loc, ((NameExpression) expr).getName(), parseTypeExpression())); } if (token == Token.EQUAL) { consume(Token.EQUAL); expr = finishNode(new OptionalType(loc, expr)); normal = false; } else { if (!normal) { throwError("unexpected token"); } } if (rest) { expr = finishNode(new RestType(new SourceLocation(loc), expr)); } params.add(expr); if (token != Token.RPAREN) { expect(Token.COMMA); } } return params; } // FunctionType := 'function' FunctionSignatureType // // FunctionSignatureType := // | TypeParameters '(' ')' ResultType // | TypeParameters '(' ParametersType ')' ResultType // | TypeParameters '(' 'this' ':' TypeName ')' ResultType // | TypeParameters '(' 'this' ':' TypeName ',' ParametersType ')' ResultType private JSDocTypeExpression parseFunctionType() throws ParseError { SourceLocation loc = loc(); boolean isNew; JSDocTypeExpression thisBinding; List params; JSDocTypeExpression result; consume(Token.NAME); // Google Closure Compiler is not implementing TypeParameters. // So we do not. if we don't get '(', we see it as error. expect(Token.LPAREN); isNew = false; params = new ArrayList(); thisBinding = null; if (token != Token.RPAREN) { // ParametersType or 'this' if (token == Token.NAME && (value.equals("this") || value.equals("new"))) { // 'this' or 'new' // 'new' is Closure Compiler extension isNew = value.equals("new"); consume(Token.NAME); expect(Token.COLON); thisBinding = parseTypeName(); if (token == Token.COMMA) { consume(Token.COMMA); params = parseParametersType(); } } else { params = parseParametersType(); } } expect(Token.RPAREN); result = null; if (token == Token.COLON) { result = parseResultType(); } return finishNode(new FunctionType(loc, thisBinding, isNew, params, result)); } // BasicTypeExpression := // '*' // | 'null' // | 'undefined' // | TypeName // | FunctionType // | UnionType // | RecordType // | ArrayType private JSDocTypeExpression parseBasicTypeExpression() throws ParseError { Context context; SourceLocation loc; switch (token) { case STAR: loc = loc(); consume(Token.STAR); return finishNode(new AllLiteral(loc)); case LPAREN: return parseUnionType(); case LBRACK: return parseArrayType(); case LBRACE: return parseRecordType(); case NAME: if (value.equals("null")) { loc = loc(); consume(Token.NAME); return finishNode(new NullLiteral(loc)); } if (value.equals("undefined")) { loc = loc(); consume(Token.NAME); return finishNode(new UndefinedLiteral(loc)); } context = save(); if (value.equals("function")) { try { return parseFunctionType(); } catch (ParseError e) { context.restore(); } } return parseTypeName(); default: return throwError("unexpected token"); } } // TypeExpression := // BasicTypeExpression // | '?' BasicTypeExpression // | '!' BasicTypeExpression // | BasicTypeExpression '?' // | BasicTypeExpression '!' // | '?' // | BasicTypeExpression '[]' private JSDocTypeExpression parseTypeExpression() throws ParseError { JSDocTypeExpression expr; SourceLocation loc = loc(); if (token == Token.QUESTION) { consume(Token.QUESTION); if (token == Token.COMMA || token == Token.EQUAL || token == Token.RBRACE || token == Token.RPAREN || token == Token.PIPE || token == Token.EOF || token == Token.RBRACK) { return finishNode(new NullableLiteral(loc)); } return finishNode(new NullableType(loc, parseBasicTypeExpression(), true)); } if (token == Token.BANG) { consume(Token.BANG); return finishNode(new NonNullableType(loc, parseBasicTypeExpression(), true)); } expr = parseBasicTypeExpression(); if (token == Token.BANG) { consume(Token.BANG); return finishNode(new NonNullableType(loc, expr, false)); } if (token == Token.QUESTION) { consume(Token.QUESTION); return finishNode(new NullableType(loc, expr, false)); } if (token == Token.LBRACK) { consume(Token.LBRACK); consume(Token.RBRACK, "expected an array-style type declaration (' + value + '[])"); List expressions = new ArrayList<>(); expressions.add(expr); return finishNode(new TypeApplication(loc, new NameExpression(loc, "Array"), expressions)); } return expr; } // TopLevelTypeExpression := // TypeExpression // | TypeUnionList // // This rule is Google Closure Compiler extension, not ES4 // like, // { number | string } // If strict to ES4, we should write it as // { (number|string) } private JSDocTypeExpression parseTop() throws ParseError { JSDocTypeExpression expr; List elements = new ArrayList(); SourceLocation loc = loc(); expr = parseTypeExpression(); if (token != Token.PIPE) { return expr; } elements.add(expr); consume(Token.PIPE); while (true) { elements.add(parseTypeExpression()); if (token != Token.PIPE) { break; } consume(Token.PIPE); } return finishNode(new UnionType(loc, elements)); } private JSDocTypeExpression parseTopParamType() throws ParseError { JSDocTypeExpression expr; SourceLocation loc = loc(); if (token == Token.REST) { consume(Token.REST); return finishNode(new RestType(loc, parseTop())); } expr = parseTop(); if (token == Token.EQUAL) { consume(Token.EQUAL); return finishNode(new OptionalType(loc, expr)); } return expr; } private JSDocTypeExpression parseType(String src) throws ParseError { JSDocTypeExpression expr; source = src; length = source.length(); index = 0; previous = 0; next(); expr = parseTop(); if (token != Token.EOF) { throwError("not reach to EOF"); } return expr; } private JSDocTypeExpression parseParamType(String src) throws ParseError { JSDocTypeExpression expr; source = src; length = source.length(); index = 0; previous = 0; next(); expr = parseTopParamType(); if (token != Token.EOF) { throwError("not reach to EOF"); } return expr; } } private TypeExpressionParser typed = new TypeExpressionParser(); private class JSDocTagParser { int index, lineNumber, lineStart, length; String source; boolean recoverable = true, sloppy = false; private int skipStars(int index) { while (index < length && isWhiteSpace(source.charAt(index)) && !isLineTerminator(source.charAt(index))) { index += 1; } while (index < length && source.charAt(index) == '*') { index += 1; } while (index < length && isWhiteSpace(source.charAt(index)) && !isLineTerminator(source.charAt(index))) { index += 1; } return index; } private char advance() { char ch = source.charAt(index); index += 1; if (isLineTerminator(ch) && !(ch == '\r' && index < length && source.charAt(index) == '\n')) { lineNumber += 1; lineStart = index; index = skipStars(index); } return ch; } private String scanTitle() { StringBuilder title = new StringBuilder(); // waste '@' advance(); while (index < length && isASCIIAlphanumeric(source.charAt(index))) { title.append(advance()); } return title.toString(); } private int seekContent() { char ch; boolean waiting = false; int last = index; while (last < length) { ch = source.charAt(last); if (isLineTerminator(ch) && !(ch == '\r' && last + 1 < length && source.charAt(last + 1) == '\n')) { lineNumber += 1; lineStart = last + 1; last = skipStars(last + 1) - 1; waiting = true; } else if (waiting) { if (ch == '@') { break; } if (!isWhiteSpace(ch)) { waiting = false; } } last += 1; } return last; } // type expression may have nest brace, such as, // { { ok: string } } // // therefore, scanning type expression with balancing braces. private JSDocTypeExpression parseType(String title, int last) throws ParseError { char ch; int brace; StringBuilder type; boolean direct = false; // search '{' while (index < last) { ch = source.charAt(index); if (isWhiteSpace(ch)) { advance(); } else if (ch == '{') { advance(); break; } else { // this is direct pattern direct = true; break; } } if (!direct) { // type expression { is found brace = 1; type = new StringBuilder(); while (index < last) { ch = source.charAt(index); if (isLineTerminator(ch)) { advance(); } else { if (ch == '}') { brace -= 1; if (brace == 0) { advance(); break; } } else if (ch == '{') { brace += 1; } type.append(advance()); } } if (brace != 0) { // braces is not balanced return throwError("Braces are not balanced"); } try { if (isParamTitle(title)) { return typed.parseParamType(type.toString()); } return typed.parseType(type.toString()); } catch (ParseError e) { // parse failed return null; } } else { return null; } } private String scanIdentifier(int last) { StringBuilder identifier = new StringBuilder(); if (!(index < length && isIdentifierStart(source.charAt(index)))) { return null; } identifier.append(advance()); while (index < last && isIdentifierPart(source.charAt(index))) { identifier.append(advance()); } return identifier.toString(); } private void skipWhiteSpace(int last) { while (index < last && (isWhiteSpace(source.charAt(index)) || isLineTerminator(source.charAt(index)))) { advance(); } } private String parseName(int last, boolean allowBrackets, boolean allowNestedParams) { StringBuilder name = new StringBuilder(); boolean useBrackets = false; skipWhiteSpace(last); if (index >= last) { return null; } if (allowBrackets && source.charAt(index) == '[') { useBrackets = true; name.append(advance()); } if (!isIdentifierStart(source.charAt(index))) { return null; } name.append(scanIdentifier(last)); if (allowNestedParams) { while (index < last && (source.charAt(index) == '.' || source.charAt(index) == '#' || source.charAt(index) == '~')) { name.append(source.charAt(index)); index += 1; name.append(scanIdentifier(last)); } } if (useBrackets) { // do we have a default value for this? if (index < last && source.charAt(index) == '=') { // consume the '='' symbol name.append(advance()); // scan in the default value while (index < last && source.charAt(index) != ']') { name.append(advance()); } } if (index >= last || source.charAt(index) != ']') { // we never found a closing ']' return null; } // collect the last ']' name.append(advance()); } return name.toString(); } boolean skipToTag() { while (index < length && source.charAt(index) != '@') { advance(); } if (index >= length) { return false; } return true; } private class Tag { public String description; public String title; List errors = new ArrayList<>(); JSDocTypeExpression type; String name; public int startLine; public int startColumn; } private class TagParser { String _title; Tag _tag; int _last; String _extra_name; TagParser(String title) { this._title = title; this._tag = new Tag(); this._tag.description = null; this._tag.title = title; this._last = 0; // space to save special information for title parsers. this._extra_name = null; } // addError(err, ...) public boolean addError(String errorText, Object... args) { this._tag.errors.add(String.format(errorText, args)); return recoverable; } public boolean parseType() { // type required titles if (isTypeParameterRequired(this._title)) { try { this._tag.type = JSDocTagParser.this.parseType(this._title, this._last); if (this._tag.type == null) { if (!isParamTitle(this._title)) { if (!this.addError("Missing or invalid tag type")) { return false; } } } } catch (ParseError error) { this._tag.type = null; if (!this.addError(error.getMessage())) { return false; } } } else if (isAllowedType(this._title)) { // optional types try { this._tag.type = JSDocTagParser.this.parseType(this._title, this._last); } catch (ParseError e) { // For optional types, lets drop the thrown error when we hit the end of the file } } return true; } private boolean _parseNamePath(boolean optional) { String name = JSDocTagParser.this.parseName(this._last, sloppy && isParamTitle(this._title), true); if (name == null) { if (!optional) { if (!this.addError("Missing or invalid tag name")) { return false; } } } this._tag.name = name; return true; } public boolean parseNamePath() { return _parseNamePath(false); } public boolean parseNamePathOptional() { return this._parseNamePath(true); } public boolean parseName() { String[] assign; String name; // param, property requires name if (isAllowedName(this._title)) { this._tag.name = JSDocTagParser.this.parseName( this._last, sloppy && isParamTitle(this._title), isAllowedNested(this._title)); if (this._tag.name == null) { if (!isNameParameterRequired(this._title)) { return true; } // it's possible the name has already been parsed but interpreted as a type // it's also possible this is a sloppy declaration, in which case it will be // fixed at the end if (isParamTitle(this._title) && this._tag.type != null && this._tag.type instanceof NameExpression) { this._extra_name = ((NameExpression) this._tag.type).getName(); this._tag.name = ((NameExpression) this._tag.type).getName(); this._tag.type = null; } else { if (!this.addError("Missing or invalid tag name")) { return false; } } } else { name = this._tag.name; if (name.charAt(0) == '[' && name.charAt(name.length() - 1) == ']') { // extract the default value if there is one // example: @param {string} [somebody=John Doe] description assign = name.substring(1, name.length() - 1).split("="); this._tag.name = assign[0]; // convert to an optional type if (this._tag.type != null && !(this._tag.type instanceof OptionalType)) { Position start = new Position(_tag.startLine, _tag.startColumn, _tag.startColumn); Position end = new Position(_tag.startLine, _tag.startColumn, _tag.startColumn); SourceLocation loc = new SourceLocation(_extra_name, start, end); this._tag.type = new OptionalType(loc, this._tag.type); } } } } return true; } private boolean parseDescription() { String description = sliceSource(source, index, this._last).trim(); if (!description.isEmpty()) { if (description.matches("(?s)^-\\s+.*")) { description = description.substring(2); } description = description.replaceAll("(?m)^\\s*\\*+\\s*", ""); this._tag.description = description; } return true; } private final Set kinds = new LinkedHashSet<>(); { kinds.add("class"); kinds.add("constant"); kinds.add("event"); kinds.add("external"); kinds.add("file"); kinds.add("function"); kinds.add("member"); kinds.add("mixin"); kinds.add("module"); kinds.add("namespace"); kinds.add("typedef"); } private boolean parseKind() { String kind = sliceSource(source, index, this._last).trim(); if (!kinds.contains(kind)) { if (!this.addError("Invalid kind name \'%s\'", kind)) { return false; } } return true; } private boolean parseAccess() { String access = sliceSource(source, index, this._last).trim(); if (!access.equals("private") && !access.equals("protected") && !access.equals("public")) { if (!this.addError("Invalid access name \'%s\'", access)) { return false; } } return true; } private boolean parseVariation() { double variation; String text = sliceSource(source, index, this._last).trim(); try { variation = Double.parseDouble(text); } catch (NumberFormatException nfe) { variation = Double.NaN; } if (Double.isNaN(variation)) { if (!this.addError("Invalid variation \'%s\'", text)) { return false; } } return true; } private boolean ensureEnd() { String shouldBeEmpty = sliceSource(source, index, this._last).trim(); if (!shouldBeEmpty.matches("^[\\s*]*$")) { if (!this.addError("Unknown content \'%s\'", shouldBeEmpty)) { return false; } } return true; } private boolean epilogue() { String description; description = this._tag.description; // un-fix potentially sloppy declaration if (isParamTitle(this._title) && this._tag.type == null && description != null && description.startsWith("[")) { if (_extra_name != null) { Position start = new Position(_tag.startLine, _tag.startColumn, _tag.startColumn); Position end = new Position(_tag.startLine, _tag.startColumn, _tag.startColumn); SourceLocation loc = new SourceLocation(_extra_name, start, end); this._tag.type = new NameExpression(loc, _extra_name); } this._tag.name = null; if (!sloppy) { if (!this.addError("Missing or invalid tag name")) { return false; } } } return true; } private Tag parse() { int oldLineNumber, oldLineStart, newLineNumber, newLineStart; // empty title if (this._title == null || this._title.isEmpty()) { if (!this.addError("Missing or invalid title")) { return null; } } // Seek to content last index. oldLineNumber = lineNumber; oldLineStart = lineStart; this._last = seekContent(); newLineNumber = lineNumber; newLineStart = lineStart; lineNumber = oldLineNumber; lineStart = oldLineStart; switch (this._title) { // http://usejsdoc.org/tags-access.html case "access": if (!parseAccess()) return null; break; // http://usejsdoc.org/tags-alias.html case "alias": if (!parseNamePath() || !ensureEnd()) return null; break; // http://usejsdoc.org/tags-augments.html case "augments": if (!parseType() || !parseNamePathOptional() || !ensureEnd()) return null; break; // http://usejsdoc.org/tags-constructor.html case "constructor": if (!parseType() || !parseNamePathOptional() || !ensureEnd()) return null; break; // Synonym: http://usejsdoc.org/tags-constructor.html case "class": if (!parseType() || !parseNamePathOptional() || !ensureEnd()) return null; break; // Synonym: http://usejsdoc.org/tags-extends.html case "extends": if (!parseType() || !parseNamePathOptional() || !ensureEnd()) return null; break; // http://usejsdoc.org/tags-deprecated.html case "deprecated": if (!parseDescription()) return null; break; // http://usejsdoc.org/tags-global.html case "global": if (!ensureEnd()) return null; break; // http://usejsdoc.org/tags-inner.html case "inner": if (!ensureEnd()) return null; break; // http://usejsdoc.org/tags-instance.html case "instance": if (!ensureEnd()) return null; break; // http://usejsdoc.org/tags-kind.html case "kind": if (!parseKind()) return null; break; // http://usejsdoc.org/tags-mixes.html case "mixes": if (!parseNamePath() || !ensureEnd()) return null; break; // http://usejsdoc.org/tags-mixin.html case "mixin": if (!parseNamePathOptional() || !ensureEnd()) return null; break; // http://usejsdoc.org/tags-member.html case "member": if (!parseType() || !parseNamePathOptional() || !ensureEnd()) return null; break; // http://usejsdoc.org/tags-method.html case "method": if (!parseNamePathOptional() || !ensureEnd()) return null; break; // http://usejsdoc.org/tags-module.html case "module": if (!parseType() || !parseNamePathOptional() || !ensureEnd()) return null; break; // Synonym: http://usejsdoc.org/tags-method.html case "func": if (!parseNamePathOptional() || !ensureEnd()) return null; break; // Synonym: http://usejsdoc.org/tags-method.html case "function": if (!parseNamePathOptional() || !ensureEnd()) return null; break; // Synonym: http://usejsdoc.org/tags-member.html case "var": if (!parseType() || !parseNamePathOptional() || !ensureEnd()) return null; break; // http://usejsdoc.org/tags-name.html case "name": if (!parseNamePath() || !ensureEnd()) return null; break; // http://usejsdoc.org/tags-namespace.html case "namespace": if (!parseType() || !parseNamePathOptional() || !ensureEnd()) return null; break; // http://usejsdoc.org/tags-private.html case "private": if (!parseType() || !parseDescription()) return null; break; // http://usejsdoc.org/tags-protected.html case "protected": if (!parseType() || !parseDescription()) return null; break; // http://usejsdoc.org/tags-public.html case "public": if (!parseType() || !parseDescription()) return null; break; // http://usejsdoc.org/tags-readonly.html case "readonly": if (!ensureEnd()) return null; break; // http://usejsdoc.org/tags-requires.html case "requires": if (!parseNamePath() || !ensureEnd()) return null; break; // http://usejsdoc.org/tags-since.html case "since": if (!parseDescription()) return null; break; // http://usejsdoc.org/tags-static.html case "static": if (!ensureEnd()) return null; break; // http://usejsdoc.org/tags-summary.html case "summary": if (!parseDescription()) return null; break; // http://usejsdoc.org/tags-this.html case "this": if (!parseNamePath() || !ensureEnd()) return null; break; // http://usejsdoc.org/tags-todo.html case "todo": if (!parseDescription()) return null; break; // http://usejsdoc.org/tags-variation.html case "variation": if (!parseVariation()) return null; break; // http://usejsdoc.org/tags-version.html case "version": if (!parseDescription()) return null; break; // default sequences default: if (!parseType() || !parseName() || !parseDescription() || !epilogue()) return null; break; } // Seek global index to end of this tag. index = this._last; lineNumber = newLineNumber; lineStart = newLineStart; return this._tag; } private Tag parseTag() { String title; Tag res; TagParser parser; int startColumn; int startLine; // skip to tag if (!skipToTag()) { return null; } startLine = lineNumber; startColumn = index - lineStart; // scan title title = scanTitle(); // construct tag parser parser = new TagParser(title); res = parser.parse(); if (res != null) { res.startLine = startLine; res.startColumn = startColumn; } return res; } // // Parse JSDoc // String scanJSDocDescription() { StringBuilder description = new StringBuilder(); char ch; boolean atAllowed; atAllowed = true; while (index < length) { ch = source.charAt(index); if (atAllowed && ch == '@') { break; } if (isLineTerminator(ch)) { atAllowed = true; } else if (atAllowed && !isWhiteSpace(ch)) { atAllowed = false; } description.append(advance()); } return description.toString().trim(); } public Pair> parse(String comment) { List tags = new ArrayList<>(); Tag tag; String description; source = comment; length = source.length(); index = 0; lineNumber = 0; lineStart = 0; recoverable = true; sloppy = true; description = scanJSDocDescription(); while (true) { tag = parseTag(); if (tag == null) { break; } tags.add(tag); } return Pair.make(description, tags); } } } }