From 9a93c9b20c5f9d90b3f6d7a66080a8d142f7c33c Mon Sep 17 00:00:00 2001 From: AlexHaxe Date: Thu, 7 Nov 2024 23:49:47 +0100 Subject: [PATCH] added ExtractType and ExtractInterface refactoring via rename lib added autodiscovery of classpath - deprecating haxe.renameSourceFolders setting for Haxe 4 refactored rename cache fixed repeated rename operations breaking rename cache fixed rename / refactor operations not working on unsaved files --- .haxerc | 4 +- haxe_libraries/rename.hxml | 6 +- src/haxeLanguageServer/Context.hx | 12 +- .../features/haxe/InlayHintFeature.hx | 2 +- .../features/haxe/RefactorFeature.hx | 147 ++++++++ .../features/haxe/RenameFeature.hx | 322 ++---------------- .../haxe/codeAction/CodeActionFeature.hx | 12 +- .../haxe/codeAction/ExtractTypeFeature.hx | 181 ---------- .../features/haxe/refactoring/EditDoc.hx | 157 +++++++++ .../features/haxe/refactoring/EditList.hx | 22 ++ .../haxe/refactoring/LanguageServerTyper.hx | 92 +++++ .../haxe/refactoring/RefactorCache.hx | 203 +++++++++++ .../helper/IdentifierHelperTest.hx | 2 +- 13 files changed, 682 insertions(+), 480 deletions(-) create mode 100644 src/haxeLanguageServer/features/haxe/RefactorFeature.hx delete mode 100644 src/haxeLanguageServer/features/haxe/codeAction/ExtractTypeFeature.hx create mode 100644 src/haxeLanguageServer/features/haxe/refactoring/EditDoc.hx create mode 100644 src/haxeLanguageServer/features/haxe/refactoring/EditList.hx create mode 100644 src/haxeLanguageServer/features/haxe/refactoring/LanguageServerTyper.hx create mode 100644 src/haxeLanguageServer/features/haxe/refactoring/RefactorCache.hx diff --git a/.haxerc b/.haxerc index 5b42a905..3a5749af 100644 --- a/.haxerc +++ b/.haxerc @@ -1,4 +1,4 @@ { - "version": "b537e99", + "version": "21e2c18", "resolveLibs": "scoped" -} +} \ No newline at end of file diff --git a/haxe_libraries/rename.hxml b/haxe_libraries/rename.hxml index 55ebc6da..69b5d11e 100644 --- a/haxe_libraries/rename.hxml +++ b/haxe_libraries/rename.hxml @@ -1,3 +1,3 @@ -# @install: lix --silent download "haxelib:/rename#2.3.0" into rename/2.3.0/haxelib --cp ${HAXE_LIBCACHE}/rename/2.3.0/haxelib/src --D rename=2.3.0 \ No newline at end of file +# @install: lix --silent download "gh://github.com/HaxeCheckstyle/haxe-rename#811d14596e7d287d371b056ab0267b9a5d5117f4" into rename/2.3.1/github/811d14596e7d287d371b056ab0267b9a5d5117f4 +-cp ${HAXE_LIBCACHE}/rename/2.3.1/github/811d14596e7d287d371b056ab0267b9a5d5117f4/src +-D rename=2.3.1 \ No newline at end of file diff --git a/src/haxeLanguageServer/Context.hx b/src/haxeLanguageServer/Context.hx index b7f9b569..f68ecf99 100644 --- a/src/haxeLanguageServer/Context.hx +++ b/src/haxeLanguageServer/Context.hx @@ -22,12 +22,14 @@ import haxeLanguageServer.features.haxe.GotoDefinitionFeature; import haxeLanguageServer.features.haxe.GotoImplementationFeature; import haxeLanguageServer.features.haxe.GotoTypeDefinitionFeature; import haxeLanguageServer.features.haxe.InlayHintFeature; +import haxeLanguageServer.features.haxe.RefactorFeature; import haxeLanguageServer.features.haxe.RenameFeature; import haxeLanguageServer.features.haxe.SignatureHelpFeature; import haxeLanguageServer.features.haxe.WorkspaceSymbolsFeature; import haxeLanguageServer.features.haxe.codeAction.CodeActionFeature; import haxeLanguageServer.features.haxe.documentSymbols.DocumentSymbolsFeature; import haxeLanguageServer.features.haxe.foldingRange.FoldingRangeFeature; +import haxeLanguageServer.features.haxe.refactoring.RefactorCache; import haxeLanguageServer.server.DisplayResult; import haxeLanguageServer.server.HaxeServer; import haxeLanguageServer.server.ServerRecording; @@ -64,6 +66,7 @@ class Context { @:nullSafety(Off) public var findReferences(default, null):FindReferencesFeature; @:nullSafety(Off) public var determinePackage(default, null):DeterminePackageFeature; @:nullSafety(Off) public var diagnostics(default, null):DiagnosticsFeature; + @:nullSafety(Off) public var refactorCache(default, null):RefactorCache; public var experimental(default, null):Null; var activeEditor:Null; @@ -376,7 +379,8 @@ class Context { new GotoTypeDefinitionFeature(this); findReferences = new FindReferencesFeature(this); determinePackage = new DeterminePackageFeature(this); - new RenameFeature(this); + refactorCache = new RefactorCache(this); + new RenameFeature(this, refactorCache); diagnostics = new DiagnosticsFeature(this); new CodeActionFeature(this); new CodeLensFeature(this); @@ -390,6 +394,7 @@ class Context { } else { haxeServer.restart(reason, function() { onServerStarted(); + refactorCache.initClassPaths(); if (activeEditor != null) { publishDiagnostics(activeEditor); } @@ -416,6 +421,7 @@ class Context { serverRecording.onDidChangeTextDocument(event); invalidateFile(uri); documents.onDidChangeTextDocument(event); + refactorCache.invalidateFile(uri.toFsPath().toString()); } } @@ -445,6 +451,7 @@ class Context { case Deleted: diagnostics.clearDiagnostics(change.uri); invalidateFile(change.uri); + refactorCache.invalidateFile(change.uri.toFsPath().toString()); case _: } } @@ -466,6 +473,9 @@ class Context { } function onDidChangeActiveTextEditor(params:{uri:DocumentUri}) { + if (!params.uri.isFile() || !params.uri.isHaxeFile()) { + return; + } activeEditor = params.uri; final document = documents.getHaxe(params.uri); if (document == null) { diff --git a/src/haxeLanguageServer/features/haxe/InlayHintFeature.hx b/src/haxeLanguageServer/features/haxe/InlayHintFeature.hx index 329dfc87..b2123f69 100644 --- a/src/haxeLanguageServer/features/haxe/InlayHintFeature.hx +++ b/src/haxeLanguageServer/features/haxe/InlayHintFeature.hx @@ -62,7 +62,7 @@ class InlayHintFeature { if (root == null) { return reject.noFittingDocument(uri); } - #if debug + #if debug_inlayhints trace('[inlayHints] requesting inlay hints for $fileName lines ${params.range.start.line}-${params.range.end.line}'); #end removeCancelledRequests(); diff --git a/src/haxeLanguageServer/features/haxe/RefactorFeature.hx b/src/haxeLanguageServer/features/haxe/RefactorFeature.hx new file mode 100644 index 00000000..68a77f51 --- /dev/null +++ b/src/haxeLanguageServer/features/haxe/RefactorFeature.hx @@ -0,0 +1,147 @@ +package haxeLanguageServer.features.haxe; + +import haxeLanguageServer.features.haxe.codeAction.CodeActionFeature.CodeActionContributor; +import haxeLanguageServer.features.haxe.codeAction.CodeActionFeature.CodeActionResolveType; +import haxeLanguageServer.features.haxe.refactoring.EditList; +import haxeLanguageServer.features.haxe.refactoring.RefactorCache; +import js.lib.Promise; +import jsonrpc.ResponseError; +import languageServerProtocol.Types.CodeAction; +import languageServerProtocol.Types.CodeActionKind; +import languageServerProtocol.Types.WorkspaceEdit; +import refactor.RefactorResult; +import refactor.Refactoring; +import refactor.refactor.RefactorType; + +class RefactorFeature implements CodeActionContributor { + final context:Context; + final refactorCache:RefactorCache; + + public function new(context:Context) { + this.context = context; + this.refactorCache = context.refactorCache; + } + + public function createCodeActions(params:CodeActionParams):Array { + var actions:Array = []; + if (params.context.only != null) { + if (params.context.only.contains(Refactor)) {} + if (params.context.only.contains(RefactorExtract)) { + actions = actions.concat(extractRefactors(params)); + } + if (params.context.only.contains(RefactorRewrite)) {} + // if (params.context.only.contains(RefactorMove)) {} // upcoming LSP 3.18.0 + if (params.context.only.contains(RefactorInline)) {} + } else { + actions = actions.concat(extractRefactors(params)); + } + + return actions; + } + + function extractRefactors(params:CodeActionParams):Array { + var actions:Array = []; + final canRefactorContext = refactorCache.makeCanRefactorContext(context.documents.getHaxe(params.textDocument.uri), params.range); + if (canRefactorContext == null) { + return actions; + } + refactorCache.updateSingleFileCache(canRefactorContext.what.fileName); + var refactorRunners:Array> = [getRefactorInfo(ExtractType), getRefactorInfo(ExtractInterface)]; + for (refactor in refactorRunners) { + if (refactor == null) { + continue; + } + switch (Refactoring.canRefactor(refactor.refactorType, canRefactorContext)) { + case Unsupported: + case Supported(title): + actions.push(makeEmptyCodeAction(title, refactor.codeActionKind, params, refactor.type)); + } + } + + return actions; + } + + function getRefactorInfo(type:CodeActionResolveType):Null { + switch (type) { + case MissingArg | ChangeFinalToVar | AddTypeHint: + return null; + case ExtractType: + return { + refactorType: RefactorExtractType, + type: type, + codeActionKind: RefactorExtract, + title: "extractType", + prefix: "[ExtractType]" + } + case ExtractInterface: + return { + refactorType: RefactorExtractInterface, + type: type, + codeActionKind: RefactorExtract, + title: "extractInterface", + prefix: "[ExtractInterface]" + } + } + } + + function makeEmptyCodeAction(title:String, kind:CodeActionKind, params:CodeActionParams, type:CodeActionResolveType):CodeAction { + return { + title: title, + kind: kind, + data: {params: params, type: type} + } + } + + public function createCodeActionEdits(context:Context, type:CodeActionResolveType, action:CodeAction, params:CodeActionParams):Promise { + var endProgress = context.startProgress("Performing Refactor Operation…"); + + var actions:Array = []; + final editList:EditList = new EditList(); + final refactorContext = refactorCache.makeRefactorContext(context.documents.getHaxe(params.textDocument.uri), params.range, editList); + if (refactorContext == null) { + return Promise.reject("failed to make refactor context"); + } + var info = getRefactorInfo(type); + if (info == null) { + return Promise.reject("failed to make refactor context"); + } + final onResolve:(?result:Null, ?debugInfo:Null) -> Void = context.startTimer("refactor/" + info.title); + refactorCache.updateFileCache(); + return Refactoring.doRefactor(info.refactorType, refactorContext).then((result:RefactorResult) -> { + var promise = switch (result) { + case NoChange: + trace(info.prefix + " no change"); + Promise.reject(ResponseError.internalError("no change")); + case NotFound: + var msg = 'could not find identifier at "${refactorContext.what.fileName}@${refactorContext.what.posStart}-${refactorContext.what.posEnd}"'; + trace('${info.prefix} $msg'); + Promise.reject(ResponseError.internalError(msg)); + case Unsupported(name): + trace('${info.prefix} refactoring not supported for "$name"'); + Promise.reject(ResponseError.internalError('refactoring not supported for "$name"')); + case DryRun: + trace(info.prefix + " dry run"); + Promise.reject(ResponseError.internalError("dry run")); + case Done: + var edit:WorkspaceEdit = {documentChanges: editList.documentChanges}; + Promise.resolve(edit); + } + endProgress(); + onResolve(null, editList.documentChanges.length + " changes"); + return promise; + }).catchError((msg) -> { + trace('${info.prefix} error: $msg'); + endProgress(); + onResolve(null, "error"); + Promise.reject(ResponseError.internalError('$msg')); + }); + } +} + +typedef RefactorInfo = { + var refactorType:RefactorType; + var type:CodeActionResolveType; + var codeActionKind:CodeActionKind; + var title:String; + var prefix:String; +} diff --git a/src/haxeLanguageServer/features/haxe/RenameFeature.hx b/src/haxeLanguageServer/features/haxe/RenameFeature.hx index 51f9e567..fc8c3cf2 100644 --- a/src/haxeLanguageServer/features/haxe/RenameFeature.hx +++ b/src/haxeLanguageServer/features/haxe/RenameFeature.hx @@ -2,27 +2,19 @@ package haxeLanguageServer.features.haxe; import byte.ByteData; import haxe.PosInfos; -import haxe.display.Display.DisplayMethods; -import haxe.display.Display.HoverDisplayItemOccurence; -import haxe.extern.EitherType; import haxe.io.Path; -import haxeLanguageServer.protocol.DotPath.getDotPath; -import js.lib.Promise; +import haxeLanguageServer.features.haxe.refactoring.EditDoc; +import haxeLanguageServer.features.haxe.refactoring.EditList; +import haxeLanguageServer.features.haxe.refactoring.LanguageServerTyper; +import haxeLanguageServer.features.haxe.refactoring.RefactorCache; import jsonrpc.CancellationToken; import jsonrpc.ResponseError; import jsonrpc.Types.NoData; -import languageServerProtocol.Types.CreateFile; -import languageServerProtocol.Types.DeleteFile; -import languageServerProtocol.Types.RenameFile; -import languageServerProtocol.Types.RenameFileKind; -import languageServerProtocol.Types.TextDocumentEdit; import languageServerProtocol.Types.WorkspaceEdit; -import refactor.CanRefactorResult; -import refactor.ITypeList; -import refactor.ITyper; +import refactor.RefactorResult; import refactor.discover.FileContentType; import refactor.discover.TraverseSources.simpleFileReader; -import refactor.rename.RenameHelper.TypeHintType; +import refactor.rename.CanRenameResult; import tokentree.TokenTree; using Lambda; @@ -30,18 +22,13 @@ using haxeLanguageServer.helper.PathHelper; class RenameFeature { final context:Context; - final converter:Haxe3DisplayOffsetConverter; - final cache:refactor.cache.IFileCache; - final typer:LanguageServerTyper; + final refactorCache:RefactorCache; static final HINT_SETTINGS = " - check `haxe.renameSourceFolders` setting (see https://github.com/vshaxe/vshaxe/wiki/Rename-Symbol)"; - public function new(context:Context) { + public function new(context:Context, refactorCache:RefactorCache) { this.context = context; - cache = new refactor.cache.MemCache(); - typer = new LanguageServerTyper(context); - - converter = new Haxe3DisplayOffsetConverter(); + this.refactorCache = refactorCache; context.languageServerProtocol.onRequest(PrepareRenameRequest.type, onPrepareRename); context.languageServerProtocol.onRequest(RenameRequest.type, onRename); @@ -50,44 +37,39 @@ class RenameFeature { function onPrepareRename(params:PrepareRenameParams, token:CancellationToken, resolve:PrepareRenameResult->Void, reject:ResponseError->Void) { final onResolve:(?result:Null, ?debugInfo:Null) -> Void = context.startTimer("textDocument/prepareRename"); final uri = params.textDocument.uri; - final doc = context.documents.getHaxe(uri); + final doc:Null = context.documents.getHaxe(uri); if (doc == null || !uri.isFile()) { return reject.noFittingDocument(uri); } final filePath:FsPath = uri.toFsPath(); - final usageContext:refactor.discover.UsageContext = makeUsageContext(); + final usageContext:refactor.discover.UsageContext = refactorCache.makeUsageContext(); usageContext.fileName = filePath.toString(); - var root:Null = doc?.tokens?.tree; - if (root == null) { - usageContext.usageCollector.parseFile(ByteData.ofString(doc.content), usageContext); - } else { - usageContext.usageCollector.parseFileWithTokens(root, usageContext); - } + refactorCache.updateSingleFileCache(filePath.toString()); final editList:EditList = new EditList(); - refactor.Refactor.canRename({ + refactor.Rename.canRename({ nameMap: usageContext.nameMap, fileList: usageContext.fileList, typeList: usageContext.typeList, what: { fileName: filePath.toString(), toName: "", - pos: converter.characterOffsetToByteOffset(doc.content, doc.offsetAt(params.position)) + pos: refactorCache.converter.characterOffsetToByteOffset(doc.content, doc.offsetAt(params.position)) }, verboseLog: function(text:String, ?pos:PosInfos) { #if debug trace('[canRename] $text'); #end }, - typer: typer - }).then((result:CanRefactorResult) -> { + typer: refactorCache.typer + }).then((result:CanRenameResult) -> { if (result == null) { reject(ResponseError.internalError("cannot rename identifier")); } - var editDoc = new EditDoc(filePath, editList, context, converter); + var editDoc = new EditDoc(filePath, editList, context, refactorCache.converter); resolve({ range: editDoc.posToRange(result.pos), placeholder: result.name @@ -102,48 +84,36 @@ class RenameFeature { function onRename(params:RenameParams, token:CancellationToken, resolve:WorkspaceEdit->Void, reject:ResponseError->Void) { final onResolve:(?result:Null, ?debugInfo:Null) -> Void = context.startTimer("textDocument/rename"); final uri = params.textDocument.uri; - final doc = context.documents.getHaxe(uri); + final doc:Null = context.documents.getHaxe(uri); if (doc == null || !uri.isFile()) { return reject.noFittingDocument(uri); } - final filePath:FsPath = uri.toFsPath(); - - final usageContext:refactor.discover.UsageContext = makeUsageContext(); - typer.typeList = usageContext.typeList; - - // TODO abort if there are unsaved documents (rename operates on fs, so positions might be off) + var endProgress = context.startProgress("Performing Rename Operation…"); - // TODO use workspace / compilation server source folders - var srcFolders:Array = ["src", "source", "Source", "test", "tests"]; - if (context.config.user.renameSourceFolders != null) { - srcFolders = context.config.user.renameSourceFolders; - } - final workspacePath = context.workspacePath.normalize(); - srcFolders = srcFolders.map(f -> Path.join([workspacePath.toString(), f])); - - refactor.discover.TraverseSources.traverseSources(srcFolders, usageContext); - usageContext.usageCollector.updateImportHx(usageContext); + final filePath:FsPath = uri.toFsPath(); + refactorCache.updateFileCache(); final editList:EditList = new EditList(); - refactor.Refactor.rename({ - nameMap: usageContext.nameMap, - fileList: usageContext.fileList, - typeList: usageContext.typeList, + refactor.Rename.rename({ + nameMap: refactorCache.nameMap, + fileList: refactorCache.fileList, + typeList: refactorCache.typeList, what: { fileName: filePath.toString(), toName: params.newName, - pos: converter.characterOffsetToByteOffset(doc.content, doc.offsetAt(params.position)) + pos: refactorCache.converter.characterOffsetToByteOffset(doc.content, doc.offsetAt(params.position)) }, forRealExecute: true, - docFactory: (filePath:String) -> new EditDoc(new FsPath(filePath), editList, context, converter), + docFactory: (filePath:String) -> new EditDoc(new FsPath(filePath), editList, context, refactorCache.converter), verboseLog: function(text:String, ?pos:PosInfos) { #if debug trace('[rename] $text'); #end }, - typer: typer - }).then((result:refactor.RefactorResult) -> { + typer: refactorCache.typer + }).then((result:RefactorResult) -> { + endProgress(); switch (result) { case NoChange: trace("[rename] no change"); @@ -162,238 +132,10 @@ class RenameFeature { } onResolve(null, editList.documentChanges.length + " changes"); }).catchError((msg) -> { + endProgress(); trace('[rename] error: $msg$HINT_SETTINGS'); + onResolve(null, "error"); reject(ResponseError.internalError('$msg$HINT_SETTINGS')); }); } - - function makeUsageContext():refactor.discover.UsageContext { - return { - fileReader: readFile, - fileName: "", - file: null, - usageCollector: new refactor.discover.UsageCollector(), - nameMap: new refactor.discover.NameMap(), - fileList: new refactor.discover.FileList(), - typeList: new refactor.discover.TypeList(), - type: null, - cache: cache - }; - } - - function readFile(path:String):FileContentType { - var fsPath = new FsPath(path); - var doc:Null = context.documents.getHaxe(fsPath.toUri()); - if (doc == null) { - return simpleFileReader(path); - } - var root:Null = doc?.tokens?.tree; - if (root != null) { - return Token(root); - } - return Text(doc.content); - } -} - -class EditList { - public var documentChanges:Array>>>; - - public function new() { - documentChanges = []; - } - - public function addEdit(edit:EitherType>>) { - documentChanges.push(edit); - } -} - -class EditDoc implements refactor.edits.IEditableDocument { - var list:EditList; - var filePath:FsPath; - var edits:Array; - var renames:Array; - final context:Context; - final converter:Haxe3DisplayOffsetConverter; - - public function new(filePath:FsPath, list:EditList, context:Context, converter:Haxe3DisplayOffsetConverter) { - this.filePath = filePath; - this.list = list; - this.context = context; - this.converter = converter; - edits = []; - renames = []; - } - - public function addChange(edit:refactor.edits.FileEdit) { - switch (edit) { - case Move(newFilePath): - renames.push({ - kind: RenameFileKind.Kind, - oldUri: filePath.toUri(), - newUri: new FsPath(newFilePath).toUri(), - options: { - overwrite: false, - ignoreIfExists: false - } - }); - case ReplaceText(text, pos): - edits.push({range: posToRange(pos), newText: text}); - case InsertText(text, pos): - edits.push({range: posToRange(pos), newText: text}); - case RemoveText(pos): - edits.push({range: posToRange(pos), newText: ""}); - } - } - - public function posToRange(pos:refactor.discover.IdentifierPos):Range { - var doc = context.documents.getHaxe(filePath.toUri()); - if (doc == null) { - // document currently not loaded -> load and find line number and character pos to build edit Range - var content:String = sys.io.File.getContent(filePath.toString()); - var lineSeparator:String = detectLineSeparator(content); - var separatorLength:Int = lineSeparator.length; - var lines:Array = content.split(lineSeparator); - var startPos:Null = null; - var endPos:Null = null; - var curLineStart:Int = 0; - var curLine:Int = 0; - - var startOffset:Int = converter.byteOffsetToCharacterOffset(content, pos.start); - var endOffset:Int = converter.byteOffsetToCharacterOffset(content, pos.end); - - for (line in lines) { - var length:Int = line.length + separatorLength; - if (startOffset > curLineStart + length) { - curLineStart += length; - curLine++; - continue; - } - if (startOffset >= curLineStart && startOffset < curLineStart + length) { - startPos = {line: curLine, character: startOffset - curLineStart}; - } - if (endOffset >= curLineStart && endOffset < curLineStart + length) { - endPos = {line: curLine, character: endOffset - curLineStart}; - break; - } - curLineStart += length; - curLine++; - } - if ((startPos == null) || (endPos == null)) { - throw '$filePath not found'; - } - return {start: cast startPos, end: cast endPos}; - } - return doc.rangeAt(converter.byteOffsetToCharacterOffset(doc.content, pos.start), converter.byteOffsetToCharacterOffset(doc.content, pos.end)); - } - - function detectLineSeparator(code:String):String { - var lineSeparator:String; - for (i in 0...code.length) { - var char = code.charAt(i); - if ((char == "\r") || (char == "\n")) { - lineSeparator = char; - if ((char == "\r") && (i + 1 < code.length)) { - char = code.charAt(i + 1); - if (char == "\n") { - lineSeparator += char; - } - } - return lineSeparator; - } - } - return "\n"; - } - - public function endEdits() { - list.addEdit({ - textDocument: { - uri: filePath.toUri(), - version: null - }, - edits: edits - }); - for (rename in renames) { - list.addEdit(rename); - } - } -} - -class LanguageServerTyper implements ITyper { - final context:Context; - - public var typeList:Null; - - public function new(context:Context) { - this.context = context; - } - - public function resolveType(filePath:String, pos:Int):Promise> { - final params = { - file: new FsPath(filePath), - offset: pos, - wasAutoTriggered: true - }; - #if debug - trace('[rename] requesting type info for $filePath@$pos'); - #end - var promise = new Promise(function(resolve:(value:Null) -> Void, reject) { - context.callHaxeMethod(DisplayMethods.Hover, params, null, function(hover) { - if (hover == null) { - #if debug - trace('[rename] received no type info for $filePath@$pos'); - #end - resolve(null); - } else { - resolve(buildTypeHint(hover, '$filePath@$pos')); - } - return null; - }, reject.handler()); - }); - return promise; - } - - function buildTypeHint(item:HoverDisplayItemOccurence, location:String):Null { - if (typeList == null) { - return null; - } - var reg = ~/Class<(.*)>/; - - var type = item?.item?.type; - if (type == null) { - return null; - } - var path = type?.args?.path; - if (path == null) { - return null; - } - if (path.moduleName == "StdTypes" && path.typeName == "Null") { - var params = type?.args?.params; - if (params == null) { - return null; - } - type = params[0]; - if (type == null) { - return null; - } - path = type?.args?.path; - if (path == null) { - return null; - } - } - if (reg.match(path.typeName)) { - var fullPath = reg.matched(1); - var parts = fullPath.split("."); - if (parts.length <= 0) { - return null; - } - @:nullSafety(Off) - path.typeName = parts.pop(); - path.pack = parts; - } - var fullPath = '${getDotPath(type)}'; - #if debug - trace('[rename] received type $fullPath for $location'); - #end - return typeList.makeTypeHintType(fullPath); - } } diff --git a/src/haxeLanguageServer/features/haxe/codeAction/CodeActionFeature.hx b/src/haxeLanguageServer/features/haxe/codeAction/CodeActionFeature.hx index 9cec30e3..cc3bebed 100644 --- a/src/haxeLanguageServer/features/haxe/codeAction/CodeActionFeature.hx +++ b/src/haxeLanguageServer/features/haxe/codeAction/CodeActionFeature.hx @@ -17,6 +17,8 @@ enum CodeActionResolveType { MissingArg; ChangeFinalToVar; AddTypeHint; + ExtractType; + ExtractInterface; } typedef CodeActionResolveData = { @@ -32,9 +34,11 @@ class CodeActionFeature { final context:Context; final contributors:Array = []; final hasCommandResolveSupport:Bool; + final refactorFeature:RefactorFeature; public function new(context) { this.context = context; + refactorFeature = new RefactorFeature(context); context.registerCapability(CodeActionRequest.type, { documentSelector: Context.haxeSelector, @@ -58,8 +62,8 @@ class CodeActionFeature { registerContributor(new ExtractVarFeature(context)); registerContributor(new ExtractConstantFeature(context)); registerContributor(new DiagnosticsCodeActionFeature(context)); + registerContributor(refactorFeature); #if debug - registerContributor(new ExtractTypeFeature(context)); registerContributor(new ExtractFunctionFeature(context)); #end } @@ -93,6 +97,7 @@ class CodeActionFeature { return; } case AddTypeHint: + case ExtractType | ExtractInterface: } switch (type) { case MissingArg, ChangeFinalToVar, AddTypeHint: @@ -119,6 +124,11 @@ class CodeActionFeature { arguments: command.arguments ?? [] }); }).catchError((e) -> reject(e)); + case ExtractType | ExtractInterface: + refactorFeature.createCodeActionEdits(context, type, action, params).then(workspaceEdit -> { + action.edit = workspaceEdit; + resolve(action); + }); } } } diff --git a/src/haxeLanguageServer/features/haxe/codeAction/ExtractTypeFeature.hx b/src/haxeLanguageServer/features/haxe/codeAction/ExtractTypeFeature.hx deleted file mode 100644 index 8387e45c..00000000 --- a/src/haxeLanguageServer/features/haxe/codeAction/ExtractTypeFeature.hx +++ /dev/null @@ -1,181 +0,0 @@ -package haxeLanguageServer.features.haxe.codeAction; - -import haxe.io.Path; -import haxeLanguageServer.features.haxe.codeAction.CodeActionFeature.CodeActionContributor; -import haxeLanguageServer.helper.WorkspaceEditHelper; -import haxeLanguageServer.tokentree.TokenTreeManager; -import languageServerProtocol.Types.CodeAction; -import languageServerProtocol.Types.CodeActionKind; -import languageServerProtocol.Types.CreateFile; -import languageServerProtocol.Types.TextDocumentEdit; -import languageServerProtocol.Types.WorkspaceEdit; -import sys.FileSystem; -import tokentree.TokenTree; -import tokentree.utils.TokenTreeCheckUtils; - -using tokentree.TokenTreeAccessHelper; - -class ExtractTypeFeature implements CodeActionContributor { - final context:Context; - - public function new(context:Context) { - this.context = context; - } - - public function createCodeActions(params:CodeActionParams):Array { - if ((params.context.only != null) && (!params.context.only.contains(RefactorExtract))) { - return []; - } - final uri = params.textDocument.uri; - final doc = context.documents.getHaxe(uri); - if (doc == null) { - return []; - } - final tokens = doc.tokens; - if (tokens == null) { - return []; - } - return try { - final fsPath:FsPath = uri.toFsPath(); - final path = new Path(fsPath.toString()); - - final types:Array = tokens.tree.filterCallback(function(token:TokenTree, index:Int):FilterResult { - switch token.tok { - case Kwd(KwdClass), Kwd(KwdInterface), Kwd(KwdEnum), Kwd(KwdAbstract), Kwd(KwdTypedef): - return FoundSkipSubtree; - default: - } - return GoDeeper; - }); - final lastImport:Null = getLastImportToken(tokens.tree); - if (isInsideConditional(lastImport)) - return []; - - // copy all imports from current file - // TODO reduce imports - final fileHeader = copyImports(doc, tokens, path.file, lastImport); - - final actions = []; - for (type in types) { - if (isInsideConditional(type)) { - // TODO support types inside conditionals - continue; - } - final nameTok:Null = type.access().firstChild().isCIdent().token; - if (nameTok == null) - continue; - - final name:String = nameTok.toString(); - if (name == path.file || path.dir == null) - continue; - - final newFileName:String = Path.join([path.dir, name + ".hx"]); - if (FileSystem.exists(newFileName)) - continue; - - final pos = tokens.getTreePos(type); - final docComment:Null = TokenTreeCheckUtils.getDocComment(type); - if (docComment != null) { - // expand pos.min to capture doc comment - pos.min = tokens.getPos(docComment).min; - } - final typeRange = doc.rangeAt(pos, Utf8); - if (params.range.intersection(typeRange) == null) { - // no overlap between selection / cursor pos and Haxe type - continue; - } - - // remove code from current file - final removeOld:TextDocumentEdit = WorkspaceEditHelper.textDocumentEdit(uri, [WorkspaceEditHelper.removeText(typeRange)]); - - // create new file - final newUri:DocumentUri = new FsPath(newFileName).toUri(); - final createFile:CreateFile = WorkspaceEditHelper.createNewFile(newUri, false, true); - - // copy file header, type and doc comment into new file - final addNewType:TextDocumentEdit = WorkspaceEditHelper.textDocumentEdit(newUri, [ - WorkspaceEditHelper.insertText(doc.positionAt(0), fileHeader + doc.getText(typeRange)) - ]); - - // TODO edits in files that use type - - final edit:WorkspaceEdit = { - documentChanges: [removeOld, createFile, addNewType] - }; - - actions.push({ - title: 'Extract $name to a new file', - kind: RefactorExtract, - edit: edit - }); - } - actions; - } catch (e) { - []; - } - } - - function copyImports(doc:HaxeDocument, tokens:TokenTreeManager, fileName:String, lastImport:Null):String { - if (lastImport == null) - return ""; - - final pos = tokens.getTreePos(lastImport); - pos.min = 0; - - final range = doc.rangeAt(pos, Utf8); - range.end.line++; - range.end.character = 0; - final fileHeader:String = doc.getText(range); - - var pack:Null = null; - tokens.tree.filterCallback(function(token:TokenTree, index:Int):FilterResult { - switch token.tok { - case Kwd(KwdPackage): - pack = token; - return SkipSubtree; - default: - return SkipSubtree; - } - }); - if (pack == null) - return fileHeader + "\n"; - - var packText:String = doc.getText(doc.rangeAt(tokens.getTreePos(pack), Utf8)); - packText = packText.replace("package ", ""); - packText = packText.replace(";", "").trim(); - if (packText.length <= 0) - packText = '${fileName}'; - else - packText += '.${fileName}'; - - return fileHeader + 'import $packText;\n\n'; - } - - function getLastImportToken(tree:TokenTree):Null { - final imports:Array = tree.filterCallback(function(token:TokenTree, index:Int):FilterResult { - switch token.tok { - case Kwd(KwdImport), Kwd(KwdUsing): - return FoundSkipSubtree; - default: - } - return GoDeeper; - }); - return imports.pop(); - } - - function isInsideConditional(token:Null):Bool { - if (token == null) - return false; - - var parent:Null = token.parent; - while (parent != null && parent.tok != null) { - switch parent.tok { - case Sharp(_): - return true; - default: - } - parent = parent.parent; - } - return false; - } -} diff --git a/src/haxeLanguageServer/features/haxe/refactoring/EditDoc.hx b/src/haxeLanguageServer/features/haxe/refactoring/EditDoc.hx new file mode 100644 index 00000000..c4048734 --- /dev/null +++ b/src/haxeLanguageServer/features/haxe/refactoring/EditDoc.hx @@ -0,0 +1,157 @@ +package haxeLanguageServer.features.haxe.refactoring; + +import haxe.extern.EitherType; +import languageServerProtocol.Types.CreateFile; +import languageServerProtocol.Types.CreateFileKind; +import languageServerProtocol.Types.DeleteFile; +import languageServerProtocol.Types.DeleteFileKind; +import languageServerProtocol.Types.RenameFile; +import languageServerProtocol.Types.RenameFileKind; +import refactor.edits.IEditableDocument; +import sys.FileSystem; + +using Lambda; +using haxeLanguageServer.helper.PathHelper; + +class EditDoc implements IEditableDocument { + var list:EditList; + var filePath:FsPath; + var edits:Array; + var creates:Array; + var renames:Array; + var deletes:Array; + final context:Context; + final converter:Haxe3DisplayOffsetConverter; + + public function new(filePath:FsPath, list:EditList, context:Context, converter:Haxe3DisplayOffsetConverter) { + this.filePath = filePath; + this.list = list; + this.context = context; + this.converter = converter; + edits = []; + creates = []; + renames = []; + deletes = []; + } + + public function addChange(edit:refactor.edits.FileEdit) { + switch (edit) { + case CreateFile(newFilePath): + creates.push({ + kind: CreateFileKind.Create, + uri: new FsPath(newFilePath).toUri(), + options: { + overwrite: false, + ignoreIfExists: false + } + }); + case Move(newFilePath): + renames.push({ + kind: RenameFileKind.Kind, + oldUri: filePath.toUri(), + newUri: new FsPath(newFilePath).toUri(), + options: { + overwrite: false, + ignoreIfExists: false + } + }); + case DeleteFile(oldFilePath): + deletes.push({ + kind: DeleteFileKind.Delete, + uri: new FsPath(oldFilePath).toUri(), + options: { + recursive: false, + ignoreIfNotExists: false + } + }); + case ReplaceText(text, pos): + edits.push({range: posToRange(pos), newText: text}); + case InsertText(text, pos): + edits.push({range: posToRange(pos), newText: text}); + case RemoveText(pos): + edits.push({range: posToRange(pos), newText: ""}); + } + } + + public function posToRange(pos:refactor.discover.IdentifierPos):Range { + if (!FileSystem.exists(filePath.toString())) { + var posNull:Position = {line: 0, character: 0}; + return {start: posNull, end: posNull}; + } + var doc:Null = context.documents.getHaxe(filePath.toUri()); + if (doc == null) { + // document currently not loaded -> load and find line number and character pos to build edit Range + var content:String = sys.io.File.getContent(filePath.toString()); + var lineSeparator:String = detectLineSeparator(content); + var separatorLength:Int = lineSeparator.length; + var lines:Array = content.split(lineSeparator); + var startPos:Null = null; + var endPos:Null = null; + var curLineStart:Int = 0; + var curLine:Int = 0; + + var startOffset:Int = converter.byteOffsetToCharacterOffset(content, pos.start); + var endOffset:Int = converter.byteOffsetToCharacterOffset(content, pos.end); + + for (line in lines) { + var length:Int = line.length + separatorLength; + if (startOffset > curLineStart + length) { + curLineStart += length; + curLine++; + continue; + } + if (startOffset >= curLineStart && startOffset < curLineStart + length) { + startPos = {line: curLine, character: startOffset - curLineStart}; + } + if (endOffset >= curLineStart && endOffset < curLineStart + length) { + endPos = {line: curLine, character: endOffset - curLineStart}; + break; + } + curLineStart += length; + curLine++; + } + if ((startPos == null) || (endPos == null)) { + throw '$filePath not found'; + } + return {start: cast startPos, end: cast endPos}; + } + return doc.rangeAt(converter.byteOffsetToCharacterOffset(doc.content, pos.start), converter.byteOffsetToCharacterOffset(doc.content, pos.end)); + } + + function detectLineSeparator(code:String):String { + var lineSeparator:String; + for (i in 0...code.length) { + var char = code.charAt(i); + if ((char == "\r") || (char == "\n")) { + lineSeparator = char; + if ((char == "\r") && (i + 1 < code.length)) { + char = code.charAt(i + 1); + if (char == "\n") { + lineSeparator += char; + } + } + return lineSeparator; + } + } + return "\n"; + } + + public function endEdits() { + for (create in creates) { + list.addEdit(create); + } + list.addEdit({ + textDocument: { + uri: filePath.toUri(), + version: null + }, + edits: edits + }); + for (rename in renames) { + list.addEdit(rename); + } + for (delete in deletes) { + list.addEdit(delete); + } + } +} diff --git a/src/haxeLanguageServer/features/haxe/refactoring/EditList.hx b/src/haxeLanguageServer/features/haxe/refactoring/EditList.hx new file mode 100644 index 00000000..6e7453be --- /dev/null +++ b/src/haxeLanguageServer/features/haxe/refactoring/EditList.hx @@ -0,0 +1,22 @@ +package haxeLanguageServer.features.haxe.refactoring; + +import haxe.extern.EitherType; +import languageServerProtocol.Types.CreateFile; +import languageServerProtocol.Types.DeleteFile; +import languageServerProtocol.Types.RenameFile; +import languageServerProtocol.Types.TextDocumentEdit; + +using Lambda; +using haxeLanguageServer.helper.PathHelper; + +class EditList { + public var documentChanges:Array>>>; + + public function new() { + documentChanges = []; + } + + public function addEdit(edit:EitherType>>) { + documentChanges.push(edit); + } +} diff --git a/src/haxeLanguageServer/features/haxe/refactoring/LanguageServerTyper.hx b/src/haxeLanguageServer/features/haxe/refactoring/LanguageServerTyper.hx new file mode 100644 index 00000000..0357d62c --- /dev/null +++ b/src/haxeLanguageServer/features/haxe/refactoring/LanguageServerTyper.hx @@ -0,0 +1,92 @@ +package haxeLanguageServer.features.haxe.refactoring; + +import haxe.display.Display.DisplayMethods; +import haxe.display.Display.HoverDisplayItemOccurence; +import haxeLanguageServer.protocol.DotPath.getDotPath; +import js.lib.Promise; +import refactor.ITypeList; +import refactor.ITyper; +import refactor.rename.RenameHelper.TypeHintType; + +using Lambda; +using haxeLanguageServer.helper.PathHelper; + +class LanguageServerTyper implements ITyper { + final context:Context; + + public var typeList:Null; + + public function new(context:Context) { + this.context = context; + } + + public function resolveType(filePath:String, pos:Int):Promise> { + final params = { + file: new FsPath(filePath), + offset: pos, + wasAutoTriggered: true + }; + #if debug + trace('[rename] requesting type info for $filePath@$pos'); + #end + var promise = new Promise(function(resolve:(value:Null) -> Void, reject) { + context.callHaxeMethod(DisplayMethods.Hover, params, null, function(hover) { + if (hover == null) { + #if debug + trace('[rename] received no type info for $filePath@$pos'); + #end + resolve(null); + } else { + resolve(buildTypeHint(hover, '$filePath@$pos')); + } + return null; + }, reject.handler()); + }); + return promise; + } + + function buildTypeHint(item:HoverDisplayItemOccurence, location:String):Null { + if (typeList == null) { + return null; + } + var reg = ~/Class<(.*)>/; + + var type = item?.item?.type; + if (type == null) { + return null; + } + var path = type?.args?.path; + if (path == null) { + return null; + } + if (path.moduleName == "StdTypes" && path.typeName == "Null") { + var params = type?.args?.params; + if (params == null) { + return null; + } + type = params[0]; + if (type == null) { + return null; + } + path = type?.args?.path; + if (path == null) { + return null; + } + } + if (reg.match(path.typeName)) { + var fullPath = reg.matched(1); + var parts = fullPath.split("."); + if (parts.length <= 0) { + return null; + } + @:nullSafety(Off) + path.typeName = parts.pop(); + path.pack = parts; + } + var fullPath = '${getDotPath(type)}'; + #if debug + trace('[rename] received type $fullPath for $location'); + #end + return typeList.makeTypeHintType(fullPath); + } +} diff --git a/src/haxeLanguageServer/features/haxe/refactoring/RefactorCache.hx b/src/haxeLanguageServer/features/haxe/refactoring/RefactorCache.hx new file mode 100644 index 00000000..94059aec --- /dev/null +++ b/src/haxeLanguageServer/features/haxe/refactoring/RefactorCache.hx @@ -0,0 +1,203 @@ +package haxeLanguageServer.features.haxe.refactoring; + +import haxe.PosInfos; +import haxe.display.Server.ServerMethods; +import haxe.io.Path; +import refactor.cache.IFileCache; +import refactor.cache.MemCache; +import refactor.discover.FileContentType; +import refactor.discover.FileList; +import refactor.discover.NameMap; +import refactor.discover.TraverseSources; +import refactor.discover.TypeList; +import refactor.discover.UsageCollector; +import refactor.discover.UsageContext; +import refactor.refactor.CanRefactorContext; +import refactor.refactor.RefactorContext; +import tokentree.TokenTree; + +using haxeLanguageServer.helper.PathHelper; + +class RefactorCache { + final context:Context; + + public final cache:IFileCache; + public final converter:Haxe3DisplayOffsetConverter; + public final typer:LanguageServerTyper; + public final usageCollector:UsageCollector; + public final nameMap:NameMap; + public final fileList:FileList; + public final typeList:TypeList; + public var classPaths:Array; + + public function new(context:Context) { + this.context = context; + + cache = new MemCache(); + typer = new LanguageServerTyper(context); + converter = new Haxe3DisplayOffsetConverter(); + usageCollector = new UsageCollector(); + nameMap = new NameMap(); + fileList = new FileList(); + typeList = new TypeList(); + classPaths = []; + initClassPaths(); + } + + function clearCache() { + cache.clear(); + nameMap.clear(); + fileList.clear(); + typeList.clear(); + } + + public function initClassPaths() { + clearCache(); + if (!context.haxeServer.supports(ServerMethods.Contexts)) { + initFromSetting(); + return; + } + context.callHaxeMethod(ServerMethods.Contexts, null, null, function(contexts) { + classPaths = []; + for (ctx in contexts) { + if (ctx?.desc == "after_init_macros") { + for (path in ctx.classPaths) { + if (path == "") { + continue; + } + if (Path.isAbsolute(path)) { + continue; + } + classPaths.push(path); + } + break; + } + } + if (classPaths.length <= 0) { + initFromSetting(); + return ""; + } + #if debug + trace("[RefactorCache] detected classpaths: " + classPaths); + #end + + init(); + return ""; + }, (err) -> initFromSetting()); + } + + function initFromSetting() { + classPaths = ["src", "source", "Source", "test", "tests"]; + if (context.config.user.renameSourceFolders != null) { + classPaths = context.config.user.renameSourceFolders; + } + init(); + } + + function init() { + var endProgress = context.startProgress("Building Refactoring Cache…"); + + final usageContext:UsageContext = makeUsageContext(); + typer.typeList = usageContext.typeList; + + final workspacePath = context.workspacePath.normalize(); + final srcFolders = classPaths.map(f -> Path.join([workspacePath.toString(), f])); + + TraverseSources.traverseSources(srcFolders, usageContext); + usageContext.usageCollector.updateImportHx(usageContext); + + endProgress(); + } + + public function updateFileCache() { + init(); + } + + public function updateSingleFileCache(uri:String) { + final usageContext:UsageContext = makeUsageContext(); + usageContext.fileName = uri; + TraverseSources.collectIdentifierData(usageContext); + } + + public function invalidateFile(uri:String) { + cache.invalidateFile(uri, nameMap, typeList); + fileList.removeFile(uri); + } + + public function makeUsageContext():UsageContext { + return { + fileReader: readFile, + fileName: "", + file: null, + usageCollector: usageCollector, + nameMap: nameMap, + fileList: fileList, + typeList: typeList, + type: null, + cache: cache + }; + } + + public function makeCanRefactorContext(doc:Null, range:Range):Null { + if (doc == null) { + return null; + } + return { + nameMap: nameMap, + fileList: fileList, + typeList: typeList, + what: { + fileName: doc.uri.toFsPath().toString(), + posStart: converter.characterOffsetToByteOffset(doc.content, doc.offsetAt(range.start)), + posEnd: converter.characterOffsetToByteOffset(doc.content, doc.offsetAt(range.end)) + }, + verboseLog: function(text:String, ?pos:PosInfos) { + #if debug + trace('[Refactor] $text'); + #end + }, + typer: typer, + fileReader: readFile, + converter: converter.byteOffsetToCharacterOffset, + }; + } + + public function makeRefactorContext(doc:Null, range:Range, editList:EditList):Null { + if (doc == null) { + return null; + } + return { + nameMap: nameMap, + fileList: fileList, + typeList: typeList, + what: { + fileName: doc.uri.toFsPath().toString(), + posStart: converter.characterOffsetToByteOffset(doc.content, doc.offsetAt(range.start)), + posEnd: converter.characterOffsetToByteOffset(doc.content, doc.offsetAt(range.end)) + }, + verboseLog: function(text:String, ?pos:PosInfos) { + #if debug + trace('[refactor] $text'); + #end + }, + typer: typer, + fileReader: readFile, + forRealExecute: true, + docFactory: (filePath:String) -> new EditDoc(new FsPath(filePath), editList, context, converter), + converter: converter.byteOffsetToCharacterOffset, + }; + } + + function readFile(path:String):FileContentType { + var fsPath = new FsPath(path); + var doc:Null = context.documents.getHaxe(fsPath.toUri()); + if (doc == null) { + return simpleFileReader(path); + } + var root:Null = doc?.tokens?.tree; + if (root != null) { + return Token(root, doc.content); + } + return Text(doc.content); + } +} diff --git a/test/haxeLanguageServer/helper/IdentifierHelperTest.hx b/test/haxeLanguageServer/helper/IdentifierHelperTest.hx index 757890d3..8f369d56 100644 --- a/test/haxeLanguageServer/helper/IdentifierHelperTest.hx +++ b/test/haxeLanguageServer/helper/IdentifierHelperTest.hx @@ -43,7 +43,7 @@ class IdentifierHelperTest extends Test { function assert(expected, original, ?posInfos) Assert.equals(expected, addNamesToSignatureType(original), posInfos); - function assertUnchanged(expectedAndOriginal, ?posInfos) + function assertUnchanged(expectedAndOriginal:Any, ?posInfos) assert(expectedAndOriginal, expectedAndOriginal, posInfos); assertUnchanged("String");