diff --git a/openrewrite/jest.config.js b/openrewrite/jest.config.js index 3793e390..dd8ae12b 100644 --- a/openrewrite/jest.config.js +++ b/openrewrite/jest.config.js @@ -3,6 +3,10 @@ module.exports = { testEnvironment: 'node', testPathIgnorePatterns: ['/node_modules/', '/dist/'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + moduleNameMapper: { + '^@openrewrite/rewrite/(.*)$': '/dist/$1', + '^@openrewrite/rewrite-remote(.*)$': '/node_modules/@openrewrite/rewrite-remote$1' + }, transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.test.json', // Adjust if your tsconfig file is named or located differently diff --git a/openrewrite/src/java/markers.ts b/openrewrite/src/java/markers.ts index 336ce12b..690f7a00 100644 --- a/openrewrite/src/java/markers.ts +++ b/openrewrite/src/java/markers.ts @@ -1 +1,19 @@ -export {} +import {LstType, Marker, MarkerSymbol, UUID} from "../core"; + +@LstType("org.openrewrite.java.marker.Semicolon") +export class Semicolon implements Marker { + [MarkerSymbol] = true; + private readonly _id: UUID; + + constructor(id: UUID) { + this._id = id; + } + + get id() { + return this._id; + } + + withId(id: UUID): Semicolon { + return id == this._id ? this : new Semicolon(id); + } +} \ No newline at end of file diff --git a/openrewrite/src/javascript/parser.ts b/openrewrite/src/javascript/parser.ts index bf4ad998..f88b849f 100644 --- a/openrewrite/src/javascript/parser.ts +++ b/openrewrite/src/javascript/parser.ts @@ -3,6 +3,7 @@ import * as J from '../java/tree'; import {Comment, JavaType, JRightPadded, Space, TextComment} from '../java/tree'; import * as JS from './tree'; import {ExecutionContext, Markers, ParseError, Parser, ParserInput, randomId, SourceFile} from "../core"; +import {Semicolon} from "../java"; export class JavaScriptParser extends Parser { @@ -93,6 +94,8 @@ for (const [key, value] of Object.entries(ts.SyntaxKind)) { } } +type TextSpan = [number, number]; + // noinspection JSUnusedGlobalSymbols export class JavaScriptParserVisitor { constructor(private readonly sourceFile: ts.SourceFile, private readonly typeChecker: ts.TypeChecker) { @@ -118,11 +121,18 @@ export class JavaScriptParserVisitor { false, null, [], - this.rightPaddedList(node.statements, this.semicolonPrefix), + this.semicolonPaddedStatementList(node), Space.EMPTY ); } + private semicolonPaddedStatementList(node: ts.SourceFile) { + return this.rightPaddedList(node.statements, this.semicolonPrefix, n => { + const last = n.getLastToken(); + return last?.kind == ts.SyntaxKind.SemicolonToken ? Markers.EMPTY.withMarkers([new Semicolon(randomId())]) : Markers.EMPTY; + }); + } + visitUnknown(node: ts.Node) { return new J.Unknown( randomId(), @@ -141,12 +151,12 @@ export class JavaScriptParserVisitor { return []; } - private rightPaddedList(nodes: ts.NodeArray, trailing?: (node: N) => Space) { + private rightPaddedList(nodes: ts.NodeArray, trailing?: (node: N) => Space, markers?: (node: N) => Markers) { return nodes.map(n => { return new JRightPadded( this.visit(n) as T, trailing ? trailing(n) : Space.EMPTY, - Markers.EMPTY + markers ? markers(n) : Markers.EMPTY ); }); } @@ -984,11 +994,22 @@ export class JavaScriptParserVisitor { return this.visitUnknown(node); } - private prefix(node: ts.Node) { - if (node.getLeadingTriviaWidth(this.sourceFile) == 0) { + private _seenTriviaSpans: TextSpan[] = []; + + private prefix(node: ts.Node): Space { + if (node.getFullStart() == node.getStart()) { return Space.EMPTY; } - // FIXME either mark ranges as consumed or implement cursor tracking + + const nodeStart = node.getFullStart(); + const span: TextSpan = [nodeStart, node.getStart()]; + var idx = binarySearch(this._seenTriviaSpans, span, compareTextSpans); + if (idx >= 0) + return Space.EMPTY; + idx = ~idx; + if (idx > 0 && this._seenTriviaSpans[idx - 1][1] > span[0]) + return Space.EMPTY; + this._seenTriviaSpans = binaryInsert(this._seenTriviaSpans, span, compareTextSpans); return prefixFromNode(node, this.sourceFile); // return Space.format(this.sourceFile.text, node.getFullStart(), node.getFullStart() + node.getLeadingTriviaWidth()); } @@ -1006,6 +1027,8 @@ function prefixFromNode(node: ts.Node, sourceFile: ts.SourceFile): Space { const text = sourceFile.getFullText(); const nodeStart = node.getFullStart(); + // FIXME merge with whitespace from previous sibling + // let previousSibling = getPreviousSibling(node); let leadingWhitespacePos = node.getStart(); // Step 1: Use forEachLeadingCommentRange to extract comments @@ -1037,3 +1060,140 @@ function prefixFromNode(node: ts.Node, sourceFile: ts.SourceFile): Space { // Step 4: Return the Space object with comments and leading whitespace return new Space(comments, whitespace.length > 0 ? whitespace : null); } + +function getPreviousSibling(node: ts.Node): ts.Node | null { + const parent = node.parent; + if (!parent) { + return null; + } + + function findContainingSyntaxList(node: ts.Node): ts.SyntaxList | null { + const parent = node.parent; + if (!parent) { + return null; + } + + const children = parent.getChildren(); + for (const child of children) { + if (child.kind == ts.SyntaxKind.SyntaxList && child.getChildren().includes(node)) { + return child as ts.SyntaxList; + } + } + + return null; + } + + const syntaxList = findContainingSyntaxList(node); + + if (syntaxList) { + const children = syntaxList.getChildren(); + const nodeIndex = children.indexOf(node); + + if (nodeIndex === -1) { + throw new Error('Node not found among SyntaxList\'s children.'); + } + + // If the node is the first child in the SyntaxList, recursively check the parent's previous sibling + if (nodeIndex === 0) { + const parentPreviousSibling = getPreviousSibling(parent); + if (!parentPreviousSibling) { + return null; + } + + // Return the last child of the parent's previous sibling + const parentSyntaxList = findContainingSyntaxList(parentPreviousSibling); + if (parentSyntaxList) { + const siblings = parentSyntaxList.getChildren(); + return siblings[siblings.length - 1] || null; + } else { + return parentPreviousSibling; + } + } + + // Otherwise, return the previous sibling in the SyntaxList + return children[nodeIndex - 1]; + } + + const parentChildren = parent.getChildren(); + const nodeIndex = parentChildren.indexOf(node); + + if (nodeIndex === -1) { + throw new Error('Node not found among parent\'s children.'); + } + + // If the node is the first child, recursively check the parent's previous sibling + if (nodeIndex === 0) { + const parentPreviousSibling = getPreviousSibling(parent); + if (!parentPreviousSibling) { + return null; + } + + // Return the last child of the parent's previous sibling + const siblings = parentPreviousSibling.getChildren(); + return siblings[siblings.length - 1] || null; + } + + // Otherwise, return the previous sibling + return parentChildren[nodeIndex - 1]; +} + +function compareTextSpans(span1: TextSpan, span2: TextSpan) { + // First, compare the first elements + if (span1[0] < span2[0]) { + return -1; + } + if (span1[0] > span2[0]) { + return 1; + } + + // If the first elements are equal, compare the second elements + if (span1[1] < span2[1]) { + return -1; + } + if (span1[1] > span2[1]) { + return 1; + } + + // If both elements are equal, the tuples are considered equal + return 0; +} + +function binarySearch(arr: T[], target: T, compare: (a: T, b: T) => number) { + let low = 0; + let high = arr.length - 1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + + const comparison = compare(arr[mid], target); + + if (comparison === 0) { + return mid; // Element found, return index + } else if (comparison < 0) { + low = mid + 1; // Search the right half + } else { + high = mid - 1; // Search the left half + } + } + return -1; // Element not found +} + +function binaryInsert(arr: T[], value: T, compare: (a: T, b: T) => number) { + let low = 0; + let high = arr.length; + + // Find the correct position using binary search logic + while (low < high) { + const mid = Math.floor((low + high) / 2); + + if (compare(arr[mid], value) < 0) { + low = mid + 1; // Value should go to the right half + } else { + high = mid; // Value should go to the left half + } + } + + // Insert the value at the found index + arr.splice(low, 0, value); + return arr; +} diff --git a/openrewrite/test/javascript/parser.test.ts b/openrewrite/test/javascript/parser.test.ts deleted file mode 100644 index 25e5cf33..00000000 --- a/openrewrite/test/javascript/parser.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import {InMemoryExecutionContext, ParserInput} from '../../src/core'; -import {JavaScriptParser} from "../../src/javascript"; -import * as J from "../../src/java/tree"; -import * as JS from "../../src/javascript/tree"; -import dedent from "dedent"; - -describe('Parser API', () => { - const parser = JavaScriptParser.builder().build(); - - test('parseInputs', () => { - const [sourceFile] = parser.parseInputs( - [new ParserInput('foo.ts', null, true, () => Buffer.from('1', 'utf8'))], - null, - new InMemoryExecutionContext() - ) as Iterable; - expect(sourceFile).toBeDefined(); - }); - - test('parseStrings', () => { - const [sourceFile] = parser.parseStrings(` - const c = 1; - /* c1*/ /*c2 */const d = 1;`) as Iterable; - expect(sourceFile).toBeDefined(); - }); -}); - -type SourceSpec = () => void; - -describe('LST mapping', () => { - const parser = JavaScriptParser.builder().build(); - - test('parseInputs', () => { - rewriteRun( - javaScript('1', sourceFile => { - expect(sourceFile).toBeDefined(); - expect(sourceFile.statements).toHaveLength(1); - let statement = sourceFile.statements[0]; - expect(statement).toBeInstanceOf(JS.ExpressionStatement); - let expression = (statement as JS.ExpressionStatement).expression; - expect(expression).toBeInstanceOf(J.Literal); - expect((expression as J.Literal).valueSource).toBe('1'); - })); - }); - - test('parseStrings', () => { - rewriteRun( - javaScript( - //language=javascript - ` - const c = 1; - /* c1*/ /*c2 */ - const d = 1; - `, cu => { - expect(cu).toBeDefined(); - expect(cu.statements).toHaveLength(2); - cu.statements.forEach(statement => { - expect(statement).toBeInstanceOf(J.Unknown); - }); - cu.padding.statements.forEach(statement => { - expect(statement.after.comments).toHaveLength(0); - expect(statement.after.whitespace).toBe(' '); - }); - }) - ); - }); - - function rewriteRun(...sourceSpecs: SourceSpec[]) { - sourceSpecs.forEach(sourceSpec => sourceSpec()); - } - - function javaScript(before: string, spec?: (sourceFile: JS.CompilationUnit) => void): SourceSpec { - return () => { - const [sourceFile] = parser.parseStrings(dedent(before)) as Iterable; - if (spec) { - spec(sourceFile); - } - }; - } -}); diff --git a/openrewrite/test/javascript/parser/expressionStatement.test.ts b/openrewrite/test/javascript/parser/expressionStatement.test.ts new file mode 100644 index 00000000..ee41ba39 --- /dev/null +++ b/openrewrite/test/javascript/parser/expressionStatement.test.ts @@ -0,0 +1,10 @@ +import {javaScript, rewriteRunWithOptions} from './testHarness'; + +describe('variable declaration mapping', () => { + test('literal with semicolon', () => { + rewriteRunWithOptions( + {normalizeIndent: false}, + javaScript('1 ;') + ); + }); +}); diff --git a/openrewrite/test/javascript/parser/literal.test.ts b/openrewrite/test/javascript/parser/literal.test.ts new file mode 100644 index 00000000..9527ebc5 --- /dev/null +++ b/openrewrite/test/javascript/parser/literal.test.ts @@ -0,0 +1,19 @@ +import * as J from "../../../dist/java/tree"; +import * as JS from "../../../dist/javascript/tree"; +import {javaScript, rewriteRunWithOptions} from './testHarness'; + +describe('literal mapping', () => { + test('number', () => { + rewriteRunWithOptions( + {normalizeIndent: false}, + javaScript('1', sourceFile => { + expect(sourceFile).toBeDefined(); + expect(sourceFile.statements).toHaveLength(1); + let statement = sourceFile.statements[0]; + expect(statement).toBeInstanceOf(JS.ExpressionStatement); + let expression = (statement as JS.ExpressionStatement).expression; + expect(expression).toBeInstanceOf(J.Literal); + expect((expression as J.Literal).valueSource).toBe('1'); + })); + }); +}); diff --git a/openrewrite/test/javascript/parser/parser.test.ts b/openrewrite/test/javascript/parser/parser.test.ts new file mode 100644 index 00000000..2957a337 --- /dev/null +++ b/openrewrite/test/javascript/parser/parser.test.ts @@ -0,0 +1,23 @@ +import {InMemoryExecutionContext, ParserInput} from '../../../dist/core'; +import {JavaScriptParser} from "../../../dist/javascript"; +import * as JS from "../../../dist/javascript/tree"; + +describe('Parser API', () => { + const parser = JavaScriptParser.builder().build(); + + test('parseInputs', () => { + const [sourceFile] = parser.parseInputs( + [new ParserInput('foo.ts', null, true, () => Buffer.from('1', 'utf8'))], + null, + new InMemoryExecutionContext() + ) as Iterable; + expect(sourceFile).toBeDefined(); + }); + + test('parseStrings', () => { + const [sourceFile] = parser.parseStrings(` + const c = 1; + /* c1*/ /*c2 */const d = 1;`) as Iterable; + expect(sourceFile).toBeDefined(); + }); +}); diff --git a/openrewrite/test/javascript/parser/testHarness.ts b/openrewrite/test/javascript/parser/testHarness.ts new file mode 100644 index 00000000..27e4c2ae --- /dev/null +++ b/openrewrite/test/javascript/parser/testHarness.ts @@ -0,0 +1,68 @@ +import {Cursor, PrinterFactory, PrintOutputCapture, SourceFile} from '../../../dist/core'; +import * as JS from "../../../dist/javascript/tree"; +import dedent from "dedent"; +import {ReceiverContext, RemotePrinterFactory, RemotingContext, SenderContext} from "@openrewrite/rewrite-remote"; +import * as deser from "@openrewrite/rewrite-remote/java/serializers"; +import {JavaScriptReceiver, JavaScriptSender} from "@openrewrite/rewrite-remote/javascript"; +import net from "net"; +import {JavaScriptParser} from "../../../dist/javascript"; + +export interface RewriteTestOptions { + normalizeIndent?: boolean + validatePrintIdempotence?: boolean +} + +export type SourceSpec = (options: RewriteTestOptions) => void; + +export function rewriteRun(...sourceSpecs: SourceSpec[]) { + rewriteRunWithOptions({}, ...sourceSpecs); +} + +export function rewriteRunWithOptions(options: RewriteTestOptions, ...sourceSpecs: SourceSpec[]) { + sourceSpecs.forEach(sourceSpec => sourceSpec(options)); +} + +const parser = JavaScriptParser.builder().build(); + +export function javaScript(before: string, spec?: (sourceFile: JS.CompilationUnit) => void): SourceSpec { + return (options: RewriteTestOptions) => { + const normalizeIndent = options.normalizeIndent === undefined || options.normalizeIndent; + const [sourceFile] = parser.parseStrings(normalizeIndent ? dedent(before) : before) as Iterable; + if (options.validatePrintIdempotence === undefined || options.validatePrintIdempotence) { + expect(print(sourceFile)).toBe(before); + } + if (spec) { + spec(sourceFile); + } + }; +} + +function print(parsed: SourceFile) { + SenderContext.register(JS.isJavaScript, () => new JavaScriptSender()); + ReceiverContext.register(JS.isJavaScript, () => new JavaScriptReceiver()); + deser.register(); + + const client = new net.Socket(); + + client.on('error', (err) => { + console.error('Socket error:', err); + }); + + // Connect to the server + client.connect(65432, 'localhost'); + + const remoting = new RemotingContext(); + + try { + remoting.connect(client); + remoting.reset(); + remoting.client?.reset(); + PrinterFactory.current = new RemotePrinterFactory(remoting.client!); + + return parsed.print(new Cursor(null, Cursor.ROOT_VALUE), new PrintOutputCapture(0)); + } finally { + client.end(); + client.destroy(); + remoting.close(); + } +} diff --git a/openrewrite/test/javascript/parser/variableDeclarations.test.ts b/openrewrite/test/javascript/parser/variableDeclarations.test.ts new file mode 100644 index 00000000..8f102aea --- /dev/null +++ b/openrewrite/test/javascript/parser/variableDeclarations.test.ts @@ -0,0 +1,27 @@ +import * as J from "../../../dist/java/tree"; +import {javaScript, rewriteRun, rewriteRunWithOptions} from './testHarness'; + +describe('variable declaration mapping', () => { + test('const', () => { + rewriteRunWithOptions( + { validatePrintIdempotence: false}, + javaScript( + //language=javascript + ` + const c = 1; + /* c1*/ /*c2 */ + const d = 1; + `, cu => { + expect(cu).toBeDefined(); + expect(cu.statements).toHaveLength(2); + cu.statements.forEach(statement => { + expect(statement).toBeInstanceOf(J.VariableDeclarations); + }); + cu.padding.statements.forEach(statement => { + expect(statement.after.comments).toHaveLength(0); + expect(statement.after.whitespace).toBe(''); + }); + }) + ); + }); +});