langium
Version:
A language engineering tool for the Language Server Protocol
304 lines (258 loc) • 10.9 kB
text/typescript
/******************************************************************************
* Copyright 2021 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 type { Position, Range } from 'vscode-languageserver-textdocument';
import type { GeneratorNode } from './generator-node.js';
import type { SourceRegion, TextRegion, TraceRegion } from './generator-tracing.js';
import { CompositeGeneratorNode, IndentNode, NewLineNode } from './generator-node.js';
import { getSourceRegion } from './generator-tracing.js';
type OffsetAndPosition = { offset: number } & Position
class Context {
defaultIndentation = ' ';
pendingIndent = true;
readonly currentIndents: IndentNode[] = [];
readonly recentNonImmediateIndents: IndentNode[] = [];
private traceData: InternalTraceRegion[] = [];
private lines: string[][] = [[]];
private length: number = 0;
constructor(defaultIndent?: string | number) {
if (typeof defaultIndent === 'string') {
this.defaultIndentation = defaultIndent;
} else if (typeof defaultIndent === 'number') {
this.defaultIndentation = ''.padStart(defaultIndent);
}
}
get content(): string {
return this.lines.map(e => e.join('')).join('');
}
get contentLength(): number {
return this.length;
}
get currentLineNumber(): number {
return this.lines.length - 1;
}
get currentLineContent(): string {
return this.lines[this.currentLineNumber].join('');
}
get currentPosition(): OffsetAndPosition {
return {
offset: this.contentLength,
line: this.currentLineNumber,
character: this.currentLineContent.length
};
}
append(value: string, isIndent?: boolean) {
if (value.length > 0) {
const beforePos = isIndent && this.currentPosition;
this.lines[this.currentLineNumber].push(value);
this.length += value.length;
if (beforePos) {
this.indentPendingTraceRegions(beforePos);
}
}
}
private indentPendingTraceRegions(before: OffsetAndPosition) {
for (let i = this.traceData.length - 1; i >= 0; i--) {
const tr = this.traceData[i];
if (tr.targetStart && tr.targetStart.offset === before.offset /* tr.targetStart.line == before.line && tr.targetStart.character === before.character*/)
tr.targetStart = this.currentPosition;
}
}
increaseIndent(node: IndentNode) {
this.currentIndents.push(node);
if (!node.indentImmediately) {
this.recentNonImmediateIndents.push(node);
}
}
decreaseIndent() {
this.currentIndents.pop();
}
get relevantIndents() {
return this.currentIndents.filter(i => !this.recentNonImmediateIndents.includes(i));
}
resetCurrentLine() {
this.length -= this.lines[this.currentLineNumber].join('').length;
this.lines[this.currentLineNumber] = [];
this.pendingIndent = true;
this.recentNonImmediateIndents.length = 0;
}
addNewLine() {
this.lines.push([]);
this.pendingIndent = true;
this.recentNonImmediateIndents.length = 0;
}
pushTraceRegion(sourceRegion: SourceRegion | undefined): InternalTraceRegion {
const region = createTraceRegion(
sourceRegion,
this.currentPosition,
it => this.traceData[this.traceData.length - 1]?.children?.push(it));
this.traceData.push(region);
return region;
}
popTraceRegion(expected: TraceRegion): InternalTraceRegion {
const traceRegion = this.traceData.pop()!;
// the following assertion can be dropped once the tracing is considered stable
this.assertTrue(traceRegion === expected, 'Trace region mismatch!');
return traceRegion;
}
getParentTraceSourceFileURI() {
for (let i = this.traceData.length - 1; i > -1; i--) {
const fileUri = this.traceData[i].sourceRegion?.fileURI;
if (fileUri)
return fileUri;
}
return undefined;
}
private assertTrue(condition: boolean, msg: string): asserts condition is true {
if (!condition) {
throw new Error(msg);
}
}
}
interface InternalTraceRegion extends TraceRegion {
targetStart?: OffsetAndPosition;
complete?: (targetEnd: OffsetAndPosition) => TraceRegion;
}
function createTraceRegion(sourceRegion: SourceRegion | undefined, targetStart: OffsetAndPosition, accept: (it: TraceRegion) => void): TraceRegion {
const result = <InternalTraceRegion>{
sourceRegion,
targetRegion: undefined!,
children: [],
targetStart,
complete: (targetEnd: OffsetAndPosition) => {
result.targetRegion = <TextRegion>{
offset: result.targetStart!.offset,
end: targetEnd.offset,
length: targetEnd.offset - result.targetStart!.offset,
range: <Range>{
start: {
line: result.targetStart!.line,
character: result.targetStart!.character
},
end: {
line: targetEnd.line,
character: targetEnd.character
},
}
};
delete result.targetStart;
if (result.children?.length === 0) {
delete result.children;
}
if (result.targetRegion?.length) {
accept(result);
}
delete result.complete;
return result;
}
};
return result;
}
export function processGeneratorNode(node: GeneratorNode, defaultIndentation?: string | number): { text: string, trace: TraceRegion } {
const context = new Context(defaultIndentation);
const trace = context.pushTraceRegion(undefined);
processNodeInternal(node, context);
context.popTraceRegion(trace);
trace.complete && trace.complete(context.currentPosition);
const singleChild = trace.children && trace.children.length === 1 ? trace.children[0] : undefined;
const singleChildTargetRegion = singleChild?.targetRegion;
const rootTargetRegion = trace.targetRegion;
if (singleChildTargetRegion && singleChild.sourceRegion
&& singleChildTargetRegion.offset === rootTargetRegion.offset
&& singleChildTargetRegion.length === rootTargetRegion.length) {
// some optimization:
// if (the root) `node` is traced (`singleChild.sourceRegion` !== undefined) and spans the entire `context.content`
// we skip the wrapping root trace object created above at the beginning of this method
return { text: context.content, trace: singleChild };
} else {
return { text: context.content, trace };
}
}
function processNodeInternal(node: GeneratorNode | string, context: Context) {
if (typeof(node) === 'string') {
processStringNode(node, context);
} else if (node instanceof IndentNode) {
processIndentNode(node, context);
} else if (node instanceof CompositeGeneratorNode) {
processCompositeNode(node, context);
} else if (node instanceof NewLineNode) {
processNewLineNode(node, context);
}
}
function hasContent(node: GeneratorNode | string, ctx: Context): boolean {
if (typeof(node) === 'string') {
return node.length !== 0; // cs: do not ignore ws only content here, enclosed within other nodes it will matter!
} else if (node instanceof CompositeGeneratorNode) {
return node.contents.some(e => hasContent(e, ctx));
} else if (node instanceof NewLineNode) {
return !(node.ifNotEmpty && ctx.currentLineContent.length === 0);
} else {
return false;
}
}
function processStringNode(node: string, context: Context) {
if (node) {
handlePendingIndent(context, false);
context.append(node);
}
}
function handlePendingIndent(ctx: Context, endOfLine: boolean) {
if (ctx.pendingIndent) {
let indent = '';
for (const indentNode of ctx.relevantIndents.filter(e => e.indentEmptyLines || !endOfLine)) {
indent += indentNode.indentation ?? ctx.defaultIndentation;
}
ctx.append(indent, true);
ctx.pendingIndent = false;
}
}
function processCompositeNode(node: CompositeGeneratorNode, context: Context) {
let traceRegion: InternalTraceRegion | undefined = undefined;
const sourceRegion: SourceRegion | undefined = getSourceRegion(node.tracedSource);
if (sourceRegion) {
traceRegion = context.pushTraceRegion(sourceRegion);
}
for (const child of node.contents) {
processNodeInternal(child, context);
}
if (traceRegion) {
context.popTraceRegion(traceRegion);
const parentsFileURI = context.getParentTraceSourceFileURI();
if (parentsFileURI && sourceRegion?.fileURI === parentsFileURI) {
// if some parent's sourceRegion refers to the same source file uri (and no other source file was referenced inbetween)
// we can drop the file uri in order to reduce repeated strings
delete sourceRegion.fileURI;
}
traceRegion.complete && traceRegion.complete(context.currentPosition);
}
}
function processIndentNode(node: IndentNode, context: Context) {
if (hasContent(node, context)) {
if (node.indentImmediately && !context.pendingIndent) {
context.append(node.indentation ?? context.defaultIndentation, true);
}
try {
context.increaseIndent(node);
processCompositeNode(node, context);
} finally {
context.decreaseIndent();
}
}
}
function processNewLineNode(node: NewLineNode, context: Context) {
if (node.ifNotEmpty && !hasNonWhitespace(context.currentLineContent)) {
context.resetCurrentLine();
} else {
handlePendingIndent(context, true);
let count = node.count;
while (count-- > 0) {
context.append(node.lineDelimiter);
context.addNewLine();
}
}
}
function hasNonWhitespace(text: string) {
return text.trimStart() !== '';
}