langium
Version:
A language engineering tool for the Language Server Protocol
692 lines (619 loc) • 22.9 kB
text/typescript
/******************************************************************************
* Copyright 2023 TypeFox GmbH
* This program and the accompanying materials are made available under the
* terms of the MIT License, which is available in the project root.
******************************************************************************/
import { Position, Range } from 'vscode-languageserver-types';
import type { CstNode } from '../syntax-tree.js';
import { NEWLINE_REGEXP, escapeRegExp } from '../utils/regexp-utils.js';
import { URI } from '../utils/uri-utils.js';
export interface JSDocComment extends JSDocValue {
readonly elements: JSDocElement[]
getTag(name: string): JSDocTag | undefined
getTags(name: string): JSDocTag[]
}
export type JSDocElement = JSDocParagraph | JSDocTag;
export type JSDocInline = JSDocTag | JSDocLine;
export interface JSDocValue {
/**
* Represents the range that this JSDoc element occupies.
* If the JSDoc was parsed from a `CstNode`, the range will represent the location in the source document.
*/
readonly range: Range
/**
* Renders this JSDoc element to a plain text representation.
*/
toString(): string
/**
* Renders this JSDoc element to a markdown representation.
*
* @param options Rendering options to customize the markdown result.
*/
toMarkdown(options?: JSDocRenderOptions): string
}
export interface JSDocParagraph extends JSDocValue {
readonly inlines: JSDocInline[]
}
export interface JSDocLine extends JSDocValue {
readonly text: string
}
export interface JSDocTag extends JSDocValue {
readonly name: string
readonly content: JSDocParagraph
readonly inline: boolean
}
export interface JSDocParseOptions {
/**
* The start symbol of your comment format. Defaults to `/**`.
*/
readonly start?: RegExp | string
/**
* The symbol that start a line of your comment format. Defaults to `*`.
*/
readonly line?: RegExp | string
/**
* The end symbol of your comment format. Defaults to `*\/`.
*/
readonly end?: RegExp | string
}
export interface JSDocRenderOptions {
/**
* Determines the style for rendering tags. Defaults to `italic`.
*/
tag?: 'plain' | 'italic' | 'bold' | 'bold-italic'
/**
* Determines the default for rendering `@link` tags. Defaults to `plain`.
*/
link?: 'code' | 'plain'
/**
* Custom tag rendering function.
* Return a markdown formatted tag or `undefined` to fall back to the default rendering.
*/
renderTag?(tag: JSDocTag): string | undefined
/**
* Custom link rendering function. Accepts a link target and a display value for the link.
* Return a markdown formatted link with the format `[$display]($link)` or `undefined` if the link is not a valid target.
*/
renderLink?(link: string, display: string): string | undefined
}
/**
* Parses a JSDoc from a `CstNode` containing a comment.
*
* @param node A `CstNode` from a parsed Langium document.
* @param options Parsing options specialized to your language. See {@link JSDocParseOptions}.
*/
export function parseJSDoc(node: CstNode, options?: JSDocParseOptions): JSDocComment;
/**
* Parses a JSDoc from a string comment.
*
* @param content A string containing the source of the JSDoc comment.
* @param start The start position the comment occupies in the source document.
* @param options Parsing options specialized to your language. See {@link JSDocParseOptions}.
*/
export function parseJSDoc(content: string, start?: Position, options?: JSDocParseOptions): JSDocComment;
export function parseJSDoc(node: CstNode | string, start?: Position | JSDocParseOptions, options?: JSDocParseOptions): JSDocComment {
let opts: JSDocParseOptions | undefined;
let position: Position | undefined;
if (typeof node === 'string') {
position = start as Position | undefined;
opts = options as JSDocParseOptions | undefined;
} else {
position = node.range.start;
opts = start as JSDocParseOptions | undefined;
}
if (!position) {
position = Position.create(0, 0);
}
const lines = getLines(node);
const normalizedOptions = normalizeOptions(opts);
const tokens = tokenize({
lines,
position,
options: normalizedOptions
});
return parseJSDocComment({
index: 0,
tokens,
position
});
}
export function isJSDoc(node: CstNode | string, options?: JSDocParseOptions): boolean {
const normalizedOptions = normalizeOptions(options);
const lines = getLines(node);
if (lines.length === 0) {
return false;
}
const first = lines[0];
const last = lines[lines.length - 1];
const firstRegex = normalizedOptions.start;
const lastRegex = normalizedOptions.end;
return Boolean(firstRegex?.exec(first)) && Boolean(lastRegex?.exec(last));
}
function getLines(node: CstNode | string): string[] {
let content = '';
if (typeof node === 'string') {
content = node;
} else {
content = node.text;
}
const lines = content.split(NEWLINE_REGEXP);
return lines;
}
// Tokenization
interface JSDocToken {
type: 'text' | 'tag' | 'inline-tag' | 'break'
content: string
range: Range
}
const tagRegex = /\s*(@([\p{L}][\p{L}\p{N}]*)?)/uy;
const inlineTagRegex = /\{(@[\p{L}][\p{L}\p{N}]*)(\s*)([^\r\n}]+)?\}/gu;
function tokenize(context: TokenizationContext): JSDocToken[] {
const tokens: JSDocToken[] = [];
let currentLine = context.position.line;
let currentCharacter = context.position.character;
for (let i = 0; i < context.lines.length; i++) {
const first = i === 0;
const last = i === context.lines.length - 1;
let line = context.lines[i];
let index = 0;
if (first && context.options.start) {
const match = context.options.start?.exec(line);
if (match) {
index = match.index + match[0].length;
}
} else {
const match = context.options.line?.exec(line);
if (match) {
index = match.index + match[0].length;
}
}
if (last) {
const match = context.options.end?.exec(line);
if (match) {
line = line.substring(0, match.index);
}
}
line = line.substring(0, lastCharacter(line));
const whitespaceEnd = skipWhitespace(line, index);
if (whitespaceEnd >= line.length) {
// Only create a break token when we already have previous tokens
if (tokens.length > 0) {
const position = Position.create(currentLine, currentCharacter);
tokens.push({
type: 'break',
content: '',
range: Range.create(position, position)
});
}
} else {
tagRegex.lastIndex = index;
const tagMatch = tagRegex.exec(line);
if (tagMatch) {
const fullMatch = tagMatch[0];
const value = tagMatch[1];
const start = Position.create(currentLine, currentCharacter + index);
const end = Position.create(currentLine, currentCharacter + index + fullMatch.length);
tokens.push({
type: 'tag',
content: value,
range: Range.create(start, end)
});
index += fullMatch.length;
index = skipWhitespace(line, index);
}
if (index < line.length) {
const rest = line.substring(index);
const inlineTagMatches = Array.from(rest.matchAll(inlineTagRegex));
tokens.push(...buildInlineTokens(inlineTagMatches, rest, currentLine, currentCharacter + index));
}
}
currentLine++;
currentCharacter = 0;
}
// Remove last break token if there is one
if (tokens.length > 0 && tokens[tokens.length - 1].type === 'break') {
return tokens.slice(0, -1);
}
return tokens;
}
function buildInlineTokens(tags: RegExpMatchArray[], line: string, lineIndex: number, characterIndex: number): JSDocToken[] {
const tokens: JSDocToken[] = [];
if (tags.length === 0) {
const start = Position.create(lineIndex, characterIndex);
const end = Position.create(lineIndex, characterIndex + line.length);
tokens.push({
type: 'text',
content: line,
range: Range.create(start, end)
});
} else {
let lastIndex = 0;
for (const match of tags) {
const matchIndex = match.index!;
const startContent = line.substring(lastIndex, matchIndex);
if (startContent.length > 0) {
tokens.push({
type: 'text',
content: line.substring(lastIndex, matchIndex),
range: Range.create(
Position.create(lineIndex, lastIndex + characterIndex),
Position.create(lineIndex, matchIndex + characterIndex)
)
});
}
let offset = startContent.length + 1;
const tagName = match[1];
tokens.push({
type: 'inline-tag',
content: tagName,
range: Range.create(
Position.create(lineIndex, lastIndex + offset + characterIndex),
Position.create(lineIndex, lastIndex + offset + tagName.length + characterIndex)
)
});
offset += tagName.length;
if (match.length === 4) {
offset += match[2].length;
const value = match[3];
tokens.push({
type: 'text',
content: value,
range: Range.create(
Position.create(lineIndex, lastIndex + offset + characterIndex),
Position.create(lineIndex, lastIndex + offset + value.length + characterIndex)
)
});
} else {
tokens.push({
type: 'text',
content: '',
range: Range.create(
Position.create(lineIndex, lastIndex + offset + characterIndex),
Position.create(lineIndex, lastIndex + offset + characterIndex)
)
});
}
lastIndex = matchIndex + match[0].length;
}
const endContent = line.substring(lastIndex);
if (endContent.length > 0) {
tokens.push({
type: 'text',
content: endContent,
range: Range.create(
Position.create(lineIndex, lastIndex + characterIndex),
Position.create(lineIndex, lastIndex + characterIndex + endContent.length)
)
});
}
}
return tokens;
}
const nonWhitespaceRegex = /\S/;
const whitespaceEndRegex = /\s*$/;
function skipWhitespace(line: string, index: number): number {
const match = line.substring(index).match(nonWhitespaceRegex);
if (match) {
return index + match.index!;
} else {
return line.length;
}
}
function lastCharacter(line: string): number | undefined {
const match = line.match(whitespaceEndRegex);
if (match && typeof match.index === 'number') {
return match.index;
}
return undefined;
}
// Parsing
function parseJSDocComment(context: ParseContext): JSDocComment {
const startPosition: Position = Position.create(context.position.line, context.position.character);
if (context.tokens.length === 0) {
return new JSDocCommentImpl([], Range.create(startPosition, startPosition));
}
const elements: JSDocElement[] = [];
while (context.index < context.tokens.length) {
const element = parseJSDocElement(context, elements[elements.length - 1]);
if (element) {
elements.push(element);
}
}
const start = elements[0]?.range.start ?? startPosition;
const end = elements[elements.length - 1]?.range.end ?? startPosition;
return new JSDocCommentImpl(elements, Range.create(start, end));
}
function parseJSDocElement(context: ParseContext, last?: JSDocElement): JSDocElement | undefined {
const next = context.tokens[context.index];
if (next.type === 'tag') {
return parseJSDocTag(context, false);
} else if (next.type === 'text' || next.type === 'inline-tag') {
return parseJSDocText(context);
} else {
appendEmptyLine(next, last);
context.index++;
return undefined;
}
}
function appendEmptyLine(token: JSDocToken, element?: JSDocElement): void {
if (element) {
const line = new JSDocLineImpl('', token.range);
if ('inlines' in element) {
element.inlines.push(line);
} else {
element.content.inlines.push(line);
}
}
}
function parseJSDocText(context: ParseContext): JSDocParagraph {
let token = context.tokens[context.index];
const firstToken = token;
let lastToken = token;
const lines: JSDocInline[] = [];
while (token && token.type !== 'break' && token.type !== 'tag') {
lines.push(parseJSDocInline(context));
lastToken = token;
token = context.tokens[context.index];
}
return new JSDocTextImpl(lines, Range.create(firstToken.range.start, lastToken.range.end));
}
function parseJSDocInline(context: ParseContext): JSDocInline {
const token = context.tokens[context.index];
if (token.type === 'inline-tag') {
return parseJSDocTag(context, true);
} else {
return parseJSDocLine(context);
}
}
function parseJSDocTag(context: ParseContext, inline: boolean): JSDocTag {
const tagToken = context.tokens[context.index++];
const name = tagToken.content.substring(1);
const nextToken = context.tokens[context.index];
if (nextToken?.type === 'text') {
if (inline) {
const docLine = parseJSDocLine(context);
return new JSDocTagImpl(
name,
new JSDocTextImpl([docLine], docLine.range),
inline,
Range.create(tagToken.range.start, docLine.range.end)
);
} else {
const textDoc = parseJSDocText(context);
return new JSDocTagImpl(
name,
textDoc,
inline,
Range.create(tagToken.range.start, textDoc.range.end)
);
}
} else {
const range = tagToken.range;
return new JSDocTagImpl(name, new JSDocTextImpl([], range), inline, range);
}
}
function parseJSDocLine(context: ParseContext): JSDocLine {
const token = context.tokens[context.index++];
return new JSDocLineImpl(token.content, token.range);
}
interface NormalizedOptions {
start?: RegExp
end?: RegExp
line?: RegExp
}
interface TokenizationContext {
position: Position
lines: string[]
options: NormalizedOptions
}
interface ParseContext {
position: Position
tokens: JSDocToken[]
index: number
}
function normalizeOptions(options?: JSDocParseOptions): NormalizedOptions {
if (!options) {
return normalizeOptions({
start: '/**',
end: '*/',
line: '*'
});
}
const { start, end, line } = options;
return {
start: normalizeOption(start, true),
end: normalizeOption(end, false),
line: normalizeOption(line, true)
};
}
function normalizeOption(option: RegExp | string | undefined, start: boolean): RegExp | undefined {
if (typeof option === 'string' || typeof option === 'object') {
const escaped = typeof option === 'string' ? escapeRegExp(option) : option.source;
if (start) {
return new RegExp(`^\\s*${escaped}`);
} else {
return new RegExp(`\\s*${escaped}\\s*$`);
}
} else {
return option;
}
}
class JSDocCommentImpl implements JSDocComment {
readonly elements: JSDocElement[];
readonly range: Range;
constructor(elements: JSDocElement[], range: Range) {
this.elements = elements;
this.range = range;
}
getTag(name: string): JSDocTag | undefined {
return this.getAllTags().find(e => e.name === name);
}
getTags(name: string): JSDocTag[] {
return this.getAllTags().filter(e => e.name === name);
}
private getAllTags(): JSDocTag[] {
return this.elements.filter(e => 'name' in e);
}
toString(): string {
let value = '';
for (const element of this.elements) {
if (value.length === 0) {
value = element.toString();
} else {
const text = element.toString();
value += fillNewlines(value) + text;
}
}
return value.trim();
}
toMarkdown(options?: JSDocRenderOptions): string {
let value = '';
for (const element of this.elements) {
if (value.length === 0) {
value = element.toMarkdown(options);
} else {
const text = element.toMarkdown(options);
value += fillNewlines(value) + text;
}
}
return value.trim();
}
}
class JSDocTagImpl implements JSDocTag {
name: string;
content: JSDocParagraph;
range: Range;
inline: boolean;
constructor(name: string, content: JSDocParagraph, inline: boolean, range: Range) {
this.name = name;
this.content = content;
this.inline = inline;
this.range = range;
}
toString(): string {
let text = `@${this.name}`;
const content = this.content.toString();
if (this.content.inlines.length === 1) {
text = `${text} ${content}`;
} else if (this.content.inlines.length > 1) {
text = `${text}\n${content}`;
}
if (this.inline) {
// Inline tags are surrounded by curly braces
return `{${text}}`;
} else {
return text;
}
}
toMarkdown(options?: JSDocRenderOptions): string {
return options?.renderTag?.(this) ?? this.toMarkdownDefault(options);
}
private toMarkdownDefault(options?: JSDocRenderOptions): string {
const content = this.content.toMarkdown(options);
if (this.inline) {
const rendered = renderInlineTag(this.name, content, options ?? {});
if (typeof rendered === 'string') {
return rendered;
}
}
let marker = '';
if (options?.tag === 'italic' || options?.tag === undefined) {
marker = '*';
} else if (options?.tag === 'bold') {
marker = '**';
} else if (options?.tag === 'bold-italic') {
marker = '***';
}
let text = `${marker}@${this.name}${marker}`;
if (this.content.inlines.length === 1) {
text = `${text} — ${content}`;
} else if (this.content.inlines.length > 1) {
text = `${text}\n${content}`;
}
if (this.inline) {
// Inline tags are surrounded by curly braces
return `{${text}}`;
} else {
return text;
}
}
}
function renderInlineTag(tag: string, content: string, options: JSDocRenderOptions): string | undefined {
if (tag === 'linkplain' || tag === 'linkcode' || tag === 'link') {
const index = content.indexOf(' ');
let display = content;
if (index > 0) {
const displayStart = skipWhitespace(content, index);
display = content.substring(displayStart);
content = content.substring(0, index);
}
if (tag === 'linkcode' || (tag === 'link' && options.link === 'code')) {
// Surround the display value in a markdown inline code block
display = `\`${display}\``;
}
const renderedLink = options.renderLink?.(content, display) ?? renderLinkDefault(content, display);
return renderedLink;
}
return undefined;
}
function renderLinkDefault(content: string, display: string): string {
try {
URI.parse(content, true);
return `[${display}](${content})`;
} catch {
return content;
}
}
class JSDocTextImpl implements JSDocParagraph {
inlines: JSDocInline[];
range: Range;
constructor(lines: JSDocInline[], range: Range) {
this.inlines = lines;
this.range = range;
}
toString(): string {
let text = '';
for (let i = 0; i < this.inlines.length; i++) {
const inline = this.inlines[i];
const next = this.inlines[i + 1];
text += inline.toString();
if (next && next.range.start.line > inline.range.start.line) {
text += '\n';
}
}
return text;
}
toMarkdown(options?: JSDocRenderOptions): string {
let text = '';
for (let i = 0; i < this.inlines.length; i++) {
const inline = this.inlines[i];
const next = this.inlines[i + 1];
text += inline.toMarkdown(options);
if (next && next.range.start.line > inline.range.start.line) {
text += '\n';
}
}
return text;
}
}
class JSDocLineImpl implements JSDocLine {
text: string;
range: Range;
constructor(text: string, range: Range) {
this.text = text;
this.range = range;
}
toString(): string {
return this.text;
}
toMarkdown(): string {
return this.text;
}
}
function fillNewlines(text: string): string {
if (text.endsWith('\n')) {
return '\n';
} else {
return '\n\n';
}
}