idea plugin 0.0.1-SNAPSHOT: basic coloring and editing aids
This commit is contained in:
1 parent
9c342c5c72
commit
06e8e1579d
46 files changed
+4192
-6
No files matched your search
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea
|
||||
|
||||
import com.intellij.openapi.fileTypes.LanguageFileType
|
||||
import javax.swing.Icon
|
||||
|
||||
object LyngFileType : LanguageFileType(LyngLanguage) {
|
||||
override fun getName(): String = "Lyng"
|
||||
override fun getDescription(): String = "Lyng language file"
|
||||
override fun getDefaultExtension(): String = "lyng"
|
||||
override fun getIcon(): Icon? = LyngIcons.FILE
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea
|
||||
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import javax.swing.Icon
|
||||
|
||||
object LyngIcons {
|
||||
val FILE: Icon = IconLoader.getIcon("/icons/lyng_file.svg", LyngIcons::class.java)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea
|
||||
|
||||
import com.intellij.lang.Language
|
||||
|
||||
object LyngLanguage : Language("Lyng")
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.idea.annotators
|
||||
|
||||
import com.intellij.lang.annotation.AnnotationHolder
|
||||
import com.intellij.lang.annotation.ExternalAnnotator
|
||||
import com.intellij.lang.annotation.HighlightSeverity
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.progress.ProgressManager
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.psi.PsiFile
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.sergeych.lyng.Compiler
|
||||
import net.sergeych.lyng.Source
|
||||
import net.sergeych.lyng.highlight.offsetOf
|
||||
import net.sergeych.lyng.idea.highlight.LyngHighlighterColors
|
||||
import net.sergeych.lyng.idea.util.IdeLenientImportProvider
|
||||
import net.sergeych.lyng.miniast.*
|
||||
|
||||
/**
|
||||
* ExternalAnnotator that runs Lyng MiniAst on the document text in background
|
||||
* and applies semantic highlighting comparable with the web highlighter.
|
||||
*/
|
||||
class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, LyngExternalAnnotator.Result>() {
|
||||
data class Input(val text: String, val modStamp: Long)
|
||||
|
||||
data class Span(val start: Int, val end: Int, val key: com.intellij.openapi.editor.colors.TextAttributesKey)
|
||||
data class Result(val modStamp: Long, val spans: List<Span>)
|
||||
|
||||
override fun collectInformation(file: PsiFile): Input? {
|
||||
val doc: Document = file.viewProvider.document ?: return null
|
||||
return Input(doc.text, doc.modificationStamp)
|
||||
}
|
||||
|
||||
override fun doAnnotate(collectedInfo: Input?): Result? {
|
||||
if (collectedInfo == null) return null
|
||||
ProgressManager.checkCanceled()
|
||||
val text = collectedInfo.text
|
||||
// Build Mini-AST using the same mechanism as web highlighter
|
||||
val sink = MiniAstBuilder()
|
||||
try {
|
||||
// Call suspend API from blocking context
|
||||
val src = Source("<ide>", text)
|
||||
val provider = IdeLenientImportProvider.create()
|
||||
runBlocking { Compiler.compileWithMini(src, provider, sink) }
|
||||
} catch (_: Throwable) {
|
||||
// Fail softly: no semantic layer this pass
|
||||
return Result(collectedInfo.modStamp, emptyList())
|
||||
}
|
||||
ProgressManager.checkCanceled()
|
||||
val mini = sink.build() ?: return Result(collectedInfo.modStamp, emptyList())
|
||||
val source = Source("<ide>", text)
|
||||
|
||||
val out = ArrayList<Span>(64)
|
||||
|
||||
fun putRange(start: Int, end: Int, key: com.intellij.openapi.editor.colors.TextAttributesKey) {
|
||||
if (start in 0..end && end <= text.length && start < end) out += Span(start, end, key)
|
||||
}
|
||||
fun putName(startPos: net.sergeych.lyng.Pos, name: String, key: com.intellij.openapi.editor.colors.TextAttributesKey) {
|
||||
val s = source.offsetOf(startPos)
|
||||
putRange(s, (s + name.length).coerceAtMost(text.length), key)
|
||||
}
|
||||
fun putMiniRange(r: MiniRange, key: com.intellij.openapi.editor.colors.TextAttributesKey) {
|
||||
val s = source.offsetOf(r.start)
|
||||
val e = source.offsetOf(r.end)
|
||||
putRange(s, e, key)
|
||||
}
|
||||
|
||||
// Declarations
|
||||
for (d in mini.declarations) {
|
||||
when (d) {
|
||||
is MiniFunDecl -> putName(d.nameStart, d.name, LyngHighlighterColors.FUNCTION)
|
||||
is MiniClassDecl -> putName(d.nameStart, d.name, LyngHighlighterColors.TYPE)
|
||||
is MiniValDecl -> putName(
|
||||
d.nameStart,
|
||||
d.name,
|
||||
if (d.mutable) LyngHighlighterColors.VARIABLE else LyngHighlighterColors.VALUE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Imports: each segment as namespace/path
|
||||
for (imp in mini.imports) {
|
||||
for (seg in imp.segments) putMiniRange(seg.range, LyngHighlighterColors.NAMESPACE)
|
||||
}
|
||||
|
||||
// Parameters
|
||||
for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) {
|
||||
for (p in fn.params) putName(p.nameStart, p.name, LyngHighlighterColors.PARAMETER)
|
||||
}
|
||||
|
||||
// Type name segments (including generics base & args)
|
||||
fun addTypeSegments(t: MiniTypeRef?) {
|
||||
when (t) {
|
||||
is MiniTypeName -> t.segments.forEach { seg ->
|
||||
val s = source.offsetOf(seg.range.start)
|
||||
putRange(s, (s + seg.name.length).coerceAtMost(text.length), LyngHighlighterColors.TYPE)
|
||||
}
|
||||
is MiniGenericType -> {
|
||||
addTypeSegments(t.base)
|
||||
t.args.forEach { addTypeSegments(it) }
|
||||
}
|
||||
is MiniFunctionType -> {
|
||||
t.receiver?.let { addTypeSegments(it) }
|
||||
t.params.forEach { addTypeSegments(it) }
|
||||
addTypeSegments(t.returnType)
|
||||
}
|
||||
is MiniTypeVar -> { /* name is in range; could be highlighted as TYPE as well */
|
||||
putMiniRange(t.range, LyngHighlighterColors.TYPE)
|
||||
}
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
for (d in mini.declarations) {
|
||||
when (d) {
|
||||
is MiniFunDecl -> {
|
||||
addTypeSegments(d.returnType)
|
||||
d.params.forEach { addTypeSegments(it.type) }
|
||||
}
|
||||
is MiniValDecl -> addTypeSegments(d.type)
|
||||
is MiniClassDecl -> {
|
||||
d.ctorFields.forEach { addTypeSegments(it.type) }
|
||||
d.classFields.forEach { addTypeSegments(it.type) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProgressManager.checkCanceled()
|
||||
return Result(collectedInfo.modStamp, out)
|
||||
}
|
||||
|
||||
override fun apply(file: PsiFile, annotationResult: Result?, holder: AnnotationHolder) {
|
||||
if (annotationResult == null) return
|
||||
// Skip if cache is up-to-date
|
||||
val doc = file.viewProvider.document
|
||||
val currentStamp = doc?.modificationStamp
|
||||
val cached = file.getUserData(CACHE_KEY)
|
||||
val result = if (cached != null && currentStamp != null && cached.modStamp == currentStamp) cached else annotationResult
|
||||
file.putUserData(CACHE_KEY, result)
|
||||
|
||||
for (s in result.spans) {
|
||||
holder.newSilentAnnotation(HighlightSeverity.INFORMATION)
|
||||
.range(TextRange(s.start, s.end))
|
||||
.textAttributes(s.key)
|
||||
.create()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val CACHE_KEY: Key<Result> = Key.create("LYNG_SEMANTIC_CACHE")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.comment
|
||||
|
||||
import com.intellij.lang.Commenter
|
||||
|
||||
class LyngCommenter : Commenter {
|
||||
override fun getLineCommentPrefix(): String = "//"
|
||||
override fun getBlockCommentPrefix(): String = "/*"
|
||||
override fun getBlockCommentSuffix(): String = "*/"
|
||||
override fun getCommentedBlockCommentPrefix(): String? = null
|
||||
override fun getCommentedBlockCommentSuffix(): String? = null
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.idea.docs
|
||||
|
||||
import com.intellij.lang.documentation.AbstractDocumentationProvider
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.sergeych.lyng.Compiler
|
||||
import net.sergeych.lyng.Source
|
||||
import net.sergeych.lyng.highlight.offsetOf
|
||||
import net.sergeych.lyng.idea.LyngLanguage
|
||||
import net.sergeych.lyng.idea.util.IdeLenientImportProvider
|
||||
import net.sergeych.lyng.miniast.*
|
||||
|
||||
/**
|
||||
* Quick Docs backed by MiniAst: when caret is on an identifier that corresponds
|
||||
* to a declaration name or parameter, render a simple HTML with kind, signature,
|
||||
* and doc summary if present.
|
||||
*/
|
||||
class LyngDocumentationProvider : AbstractDocumentationProvider() {
|
||||
private val log = Logger.getInstance(LyngDocumentationProvider::class.java)
|
||||
override fun generateDoc(element: PsiElement?, originalElement: PsiElement?): String? {
|
||||
if (element == null) return null
|
||||
val file: PsiFile = element.containingFile ?: return null
|
||||
val document: Document = file.viewProvider.document ?: return null
|
||||
val text = document.text
|
||||
|
||||
// Determine caret/lookup offset from the element range
|
||||
val offset = originalElement?.textRange?.startOffset ?: element.textRange.startOffset
|
||||
val idRange = wordRangeAt(text, offset) ?: run {
|
||||
log.info("[LYNG_DEBUG] QuickDoc: no word at offset=$offset in ${file.name}")
|
||||
return null
|
||||
}
|
||||
if (idRange.isEmpty) return null
|
||||
val ident = text.substring(idRange.startOffset, idRange.endOffset)
|
||||
log.info("[LYNG_DEBUG] QuickDoc: ident='$ident' at ${idRange.startOffset}..${idRange.endOffset} in ${file.name}")
|
||||
|
||||
// Build MiniAst for this file (fast and resilient). Best-effort; on failure return null.
|
||||
val sink = MiniAstBuilder()
|
||||
try {
|
||||
// Use lenient import provider so unresolved imports (e.g., lyng.io.fs) don't break docs
|
||||
val src = Source("<ide>", text)
|
||||
val provider = IdeLenientImportProvider.create()
|
||||
runBlocking { Compiler.compileWithMini(src, provider, sink) }
|
||||
} catch (t: Throwable) {
|
||||
log.warn("[LYNG_DEBUG] QuickDoc: compileWithMini failed: ${t.message}")
|
||||
return null
|
||||
}
|
||||
val mini = sink.build() ?: return null
|
||||
val source = Source("<ide>", text)
|
||||
|
||||
// Try resolve to: function param at position, function/class/val declaration at position
|
||||
// 1) Check declarations whose name range contains offset
|
||||
for (d in mini.declarations) {
|
||||
val s = source.offsetOf(d.nameStart)
|
||||
val e = (s + d.name.length).coerceAtMost(text.length)
|
||||
if (offset in s until e) {
|
||||
log.info("[LYNG_DEBUG] QuickDoc: matched decl '${d.name}' kind=${d::class.simpleName}")
|
||||
return renderDeclDoc(d)
|
||||
}
|
||||
}
|
||||
// 2) Check parameters of functions
|
||||
for (fn in mini.declarations.filterIsInstance<MiniFunDecl>()) {
|
||||
for (p in fn.params) {
|
||||
val s = source.offsetOf(p.nameStart)
|
||||
val e = (s + p.name.length).coerceAtMost(text.length)
|
||||
if (offset in s until e) {
|
||||
log.info("[LYNG_DEBUG] QuickDoc: matched param '${p.name}' in fun '${fn.name}'")
|
||||
return renderParamDoc(fn, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3) As a fallback, if the caret is on an identifier text that matches any declaration name, show that
|
||||
mini.declarations.firstOrNull { it.name == ident }?.let {
|
||||
log.info("[LYNG_DEBUG] QuickDoc: fallback by name '${it.name}' kind=${it::class.simpleName}")
|
||||
return renderDeclDoc(it)
|
||||
}
|
||||
|
||||
log.info("[LYNG_DEBUG] QuickDoc: nothing found for ident='$ident'")
|
||||
return null
|
||||
}
|
||||
|
||||
override fun getCustomDocumentationElement(
|
||||
editor: Editor,
|
||||
file: PsiFile,
|
||||
contextElement: PsiElement?,
|
||||
targetOffset: Int
|
||||
): PsiElement? {
|
||||
// Ensure our provider gets a chance for Lyng files regardless of PSI sophistication
|
||||
if (file.language != LyngLanguage) return null
|
||||
return contextElement ?: file.findElementAt(targetOffset)
|
||||
}
|
||||
|
||||
private fun renderDeclDoc(d: MiniDecl): String {
|
||||
val title = when (d) {
|
||||
is MiniFunDecl -> "function ${d.name}${signatureOf(d)}"
|
||||
is MiniClassDecl -> "class ${d.name}"
|
||||
is MiniValDecl -> if (d.mutable) "var ${d.name}${typeOf(d.type)}" else "val ${d.name}${typeOf(d.type)}"
|
||||
else -> d.name
|
||||
}
|
||||
// Show full detailed documentation, not just the summary
|
||||
val doc = d.doc?.raw?.let { htmlEscape(it).replace("\n", "<br/>") }
|
||||
val sb = StringBuilder()
|
||||
sb.append("<div class='doc-title'>").append(htmlEscape(title)).append("</div>")
|
||||
if (!doc.isNullOrBlank()) sb.append("<div class='doc-body'>").append(doc).append("</div>")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun renderParamDoc(fn: MiniFunDecl, p: MiniParam): String {
|
||||
val title = "parameter ${p.name}${typeOf(p.type)} in ${fn.name}${signatureOf(fn)}"
|
||||
return "<div class='doc-title'>${htmlEscape(title)}</div>"
|
||||
}
|
||||
|
||||
private fun typeOf(t: MiniTypeRef?): String = when (t) {
|
||||
is MiniTypeName -> ": ${t.segments.joinToString(".") { it.name }}"
|
||||
is MiniGenericType -> ": ${typeOf(t.base).removePrefix(": ")}<${t.args.joinToString(", ") { typeOf(it).removePrefix(": ") }}>"
|
||||
is MiniFunctionType -> ": (..) -> .."
|
||||
is MiniTypeVar -> ": ${t.name}"
|
||||
null -> ""
|
||||
}
|
||||
|
||||
private fun signatureOf(fn: MiniFunDecl): String {
|
||||
val params = fn.params.joinToString(", ") { p ->
|
||||
val ts = typeOf(p.type)
|
||||
if (ts.isNotBlank()) "${p.name}${ts}" else p.name
|
||||
}
|
||||
val ret = typeOf(fn.returnType)
|
||||
return "(${params})${ret}"
|
||||
}
|
||||
|
||||
private fun htmlEscape(s: String): String = buildString(s.length) {
|
||||
for (ch in s) append(
|
||||
when (ch) {
|
||||
'<' -> "<"
|
||||
'>' -> ">"
|
||||
'&' -> "&"
|
||||
'"' -> """
|
||||
else -> ch
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun wordRangeAt(text: String, offset: Int): TextRange? {
|
||||
if (text.isEmpty()) return null
|
||||
var s = offset.coerceIn(0, text.length)
|
||||
var e = s
|
||||
while (s > 0 && isIdentChar(text[s - 1])) s--
|
||||
while (e < text.length && isIdentChar(text[e])) e++
|
||||
return if (e > s) TextRange(s, e) else null
|
||||
}
|
||||
|
||||
private fun isIdentChar(c: Char): Boolean = c == '_' || c.isLetterOrDigit()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.docs
|
||||
|
||||
// 243: We do not use DocumentationTarget API here. Quick Docs works via
|
||||
// AbstractDocumentationProvider registered as lang.documentationProvider.
|
||||
internal object LyngDocumentationTargetsPlaceholder
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.editor
|
||||
|
||||
import com.intellij.codeInsight.editorActions.BackspaceHandlerDelegate
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.psi.PsiFile
|
||||
|
||||
/**
|
||||
* Backspace handler (currently inactive, not registered). Minimal stub to keep build green.
|
||||
* We will enable and implement smart behavior after API verification on the target IDE.
|
||||
*/
|
||||
class LyngBackspaceHandler : BackspaceHandlerDelegate() {
|
||||
override fun beforeCharDeleted(c: Char, file: PsiFile, editor: Editor) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
override fun charDeleted(c: Char, file: PsiFile, editor: Editor): Boolean {
|
||||
// no-op; let default behavior stand
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.editor
|
||||
|
||||
/**
|
||||
* Disabled placeholder. We currently use the EditorPaste action handler (LyngPasteHandler)
|
||||
* which works across IDE builds without relying on RawText API.
|
||||
*/
|
||||
class LyngCopyPastePreProcessorDisabled
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.editor
|
||||
|
||||
/**
|
||||
* Placeholder for 2024.3+ RawText-based CopyPastePreProcessor.
|
||||
* Not compiled against current SDK classpath; kept for future activation.
|
||||
*/
|
||||
class LyngCopyPastePreProcessor243Disabled
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.editor
|
||||
|
||||
import com.intellij.application.options.CodeStyle
|
||||
import com.intellij.codeInsight.editorActions.enter.EnterHandlerDelegate
|
||||
import com.intellij.codeInsight.editorActions.enter.EnterHandlerDelegate.Result
|
||||
import com.intellij.openapi.actionSystem.DataContext
|
||||
import com.intellij.openapi.command.WriteCommandAction
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.editor.Caret
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.editor.actionSystem.EditorActionHandler
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Ref
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.psi.PsiDocumentManager
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.codeStyle.CodeStyleManager
|
||||
import net.sergeych.lyng.format.LyngFormatConfig
|
||||
import net.sergeych.lyng.format.LyngFormatter
|
||||
import net.sergeych.lyng.idea.LyngLanguage
|
||||
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
|
||||
|
||||
class LyngEnterHandler : EnterHandlerDelegate {
|
||||
private val log = Logger.getInstance(LyngEnterHandler::class.java)
|
||||
override fun preprocessEnter(
|
||||
file: PsiFile,
|
||||
editor: Editor,
|
||||
caretOffset: Ref<Int>,
|
||||
caretAdvance: Ref<Int>,
|
||||
dataContext: DataContext,
|
||||
originalHandler: EditorActionHandler?
|
||||
): Result {
|
||||
if (file.language != LyngLanguage) return Result.Continue
|
||||
if (log.isDebugEnabled) log.debug("[LyngEnter] preprocess in Lyng file at caretOffset=${caretOffset.get()}")
|
||||
// Let the platform insert the newline; we will fix indentation in postProcessEnter.
|
||||
return Result.Continue
|
||||
}
|
||||
|
||||
override fun postProcessEnter(file: PsiFile, editor: Editor, dataContext: DataContext): Result {
|
||||
if (file.language != LyngLanguage) return Result.Continue
|
||||
val project = file.project
|
||||
val doc = editor.document
|
||||
val psiManager = PsiDocumentManager.getInstance(project)
|
||||
psiManager.commitDocument(doc)
|
||||
|
||||
// Handle all carets independently to keep multi-caret scenarios sane
|
||||
for (caret in editor.caretModel.allCarets) {
|
||||
val line = doc.safeLineNumber(caret.offset)
|
||||
if (line < 0) continue
|
||||
if (log.isDebugEnabled) log.debug("[LyngEnter] postProcess at line=$line offset=${caret.offset}")
|
||||
// Adjust previous '}' line if applicable, then indent the current line using our rules
|
||||
adjustBraceAndCurrentIndent(project, file, doc, line)
|
||||
|
||||
// After indenting, ensure caret sits after the computed indent of this new line
|
||||
moveCaretToIndentIfOnLeadingWs(editor, doc, file, line, caret)
|
||||
}
|
||||
return Result.Continue
|
||||
}
|
||||
|
||||
private fun adjustBraceAndCurrentIndent(project: Project, file: PsiFile, doc: Document, currentLine: Int) {
|
||||
val prevLine = currentLine - 1
|
||||
if (prevLine >= 0) {
|
||||
val prevText = doc.getLineText(prevLine)
|
||||
val trimmed = prevText.trimStart()
|
||||
// consider only code part before // comment
|
||||
val code = trimmed.substringBefore("//").trim()
|
||||
if (code == "}") {
|
||||
// Optionally reindent the enclosed block without manually touching the '}' line.
|
||||
val settings = LyngFormatterSettings.getInstance(project)
|
||||
if (settings.reindentClosedBlockOnEnter) {
|
||||
reindentClosedBlockAroundBrace(project, file, doc, prevLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Adjust indent for the current (new) line
|
||||
val currentStart = doc.getLineStartOffsetSafe(currentLine)
|
||||
val csm = CodeStyleManager.getInstance(project)
|
||||
csm.adjustLineIndent(file, currentStart)
|
||||
|
||||
// Fallback: if the platform didn't physically insert indentation, compute it from our formatter and apply
|
||||
val lineStart = doc.getLineStartOffset(currentLine)
|
||||
val lineEnd = doc.getLineEndOffset(currentLine)
|
||||
val desiredIndent = computeDesiredIndent(project, doc, currentLine)
|
||||
val firstNonWs = findFirstNonWs(doc, lineStart, lineEnd)
|
||||
val currentIndentLen = firstNonWs - lineStart
|
||||
if (desiredIndent.isNotEmpty() || currentIndentLen != 0) {
|
||||
// Replace existing leading whitespace to match desired indent exactly
|
||||
val replaceFrom = lineStart
|
||||
val replaceTo = lineStart + currentIndentLen
|
||||
if (doc.getText(TextRange(replaceFrom, replaceTo)) != desiredIndent) {
|
||||
com.intellij.openapi.command.WriteCommandAction.runWriteCommandAction(project) {
|
||||
doc.replaceString(replaceFrom, replaceTo, desiredIndent)
|
||||
}
|
||||
PsiDocumentManager.getInstance(project).commitDocument(doc)
|
||||
if (log.isDebugEnabled) {
|
||||
val dbg = desiredIndent.replace("\t", "\\t")
|
||||
log.debug("[LyngEnter] rewrote current line leading WS to '$dbg' at line=$currentLine")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reindentClosedBlockAroundBrace(project: Project, file: PsiFile, doc: Document, braceLine: Int) {
|
||||
// Find the absolute index of the '}' at or before end of braceLine
|
||||
val braceLineStart = doc.getLineStartOffset(braceLine)
|
||||
val braceLineEnd = doc.getLineEndOffset(braceLine)
|
||||
val rawBraceLine = doc.getText(TextRange(braceLineStart, braceLineEnd))
|
||||
val codeBraceLine = rawBraceLine.substringBefore("//")
|
||||
val closeRel = codeBraceLine.lastIndexOf('}')
|
||||
if (closeRel < 0) return
|
||||
val closeAbs = braceLineStart + closeRel
|
||||
|
||||
// Compute the enclosing block range in raw text (document char sequence)
|
||||
val blockRange = net.sergeych.lyng.format.BraceUtils.findEnclosingBlockRange(doc.charsSequence, closeAbs, includeTrailingNewline = true)
|
||||
?: return
|
||||
|
||||
val options = CodeStyle.getIndentOptions(project, doc)
|
||||
val cfg = LyngFormatConfig(
|
||||
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
|
||||
useTabs = options.USE_TAB_CHARACTER,
|
||||
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
|
||||
)
|
||||
|
||||
// Run partial reindent over the slice and replace only if changed
|
||||
val whole = doc.text
|
||||
val updated = LyngFormatter.reindentRange(whole, blockRange, cfg, preserveBaseIndent = true)
|
||||
if (updated != whole) {
|
||||
WriteCommandAction.runWriteCommandAction(project) {
|
||||
doc.replaceString(0, doc.textLength, updated)
|
||||
}
|
||||
PsiDocumentManager.getInstance(project).commitDocument(doc)
|
||||
if (log.isDebugEnabled) log.debug("[LyngEnter] reindented closed block range=${'$'}blockRange")
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveCaretToIndentIfOnLeadingWs(editor: Editor, doc: Document, file: PsiFile, line: Int, caret: Caret) {
|
||||
if (line < 0 || line >= doc.lineCount) return
|
||||
val lineStart = doc.getLineStartOffset(line)
|
||||
val lineEnd = doc.getLineEndOffset(line)
|
||||
val desiredIndent = computeDesiredIndent(file.project, doc, line)
|
||||
val firstNonWs = (lineStart + desiredIndent.length).coerceAtMost(doc.textLength)
|
||||
val caretOffset = caret.offset
|
||||
// If caret is at beginning of the line or still within the leading whitespace, move it after indent
|
||||
val target = firstNonWs.coerceIn(lineStart, lineEnd)
|
||||
caret.moveToOffset(target)
|
||||
}
|
||||
|
||||
private fun computeDesiredIndent(project: Project, doc: Document, line: Int): String {
|
||||
val options = CodeStyle.getIndentOptions(project, doc)
|
||||
val start = 0
|
||||
val end = doc.getLineEndOffset(line)
|
||||
val snippet = doc.getText(TextRange(start, end))
|
||||
val isBlankLine = doc.getLineText(line).trim().isEmpty()
|
||||
val snippetForCalc = if (isBlankLine) snippet + "x" else snippet
|
||||
val cfg = LyngFormatConfig(
|
||||
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
|
||||
useTabs = options.USE_TAB_CHARACTER,
|
||||
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
|
||||
)
|
||||
val formatted = LyngFormatter.reindent(snippetForCalc, cfg)
|
||||
val lastNl = formatted.lastIndexOf('\n')
|
||||
val lastLine = if (lastNl >= 0) formatted.substring(lastNl + 1) else formatted
|
||||
val wsLen = lastLine.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) lastLine.length else it }
|
||||
return lastLine.substring(0, wsLen)
|
||||
}
|
||||
|
||||
private fun findFirstNonWs(doc: Document, start: Int, end: Int): Int {
|
||||
var i = start
|
||||
val text = doc.charsSequence
|
||||
while (i < end) {
|
||||
val ch = text[i]
|
||||
if (ch != ' ' && ch != '\t') break
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
private fun Document.safeLineNumber(offset: Int): Int =
|
||||
getLineNumber(offset.coerceIn(0, textLength))
|
||||
|
||||
private fun Document.getLineText(line: Int): String {
|
||||
if (line < 0 || line >= lineCount) return ""
|
||||
val start = getLineStartOffset(line)
|
||||
val end = getLineEndOffset(line)
|
||||
return getText(TextRange(start, end))
|
||||
}
|
||||
|
||||
private fun Document.getLineStartOffsetSafe(line: Int): Int =
|
||||
if (line < 0) 0 else getLineStartOffset(line.coerceAtMost(lineCount - 1).coerceAtLeast(0))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.editor
|
||||
|
||||
/**
|
||||
* Disabled placeholder: we rely on LyngPasteHandler (EditorPaste action handler).
|
||||
*/
|
||||
class LyngOnPasteProcessorDisabled
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.editor
|
||||
|
||||
import com.intellij.application.options.CodeStyle
|
||||
import com.intellij.openapi.actionSystem.DataContext
|
||||
import com.intellij.openapi.command.WriteCommandAction
|
||||
import com.intellij.openapi.editor.Caret
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.editor.actionSystem.EditorWriteActionHandler
|
||||
import com.intellij.openapi.ide.CopyPasteManager
|
||||
import com.intellij.psi.PsiDocumentManager
|
||||
import net.sergeych.lyng.format.LyngFormatConfig
|
||||
import net.sergeych.lyng.format.LyngFormatter
|
||||
import net.sergeych.lyng.idea.LyngLanguage
|
||||
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
|
||||
/**
|
||||
* Smart Paste using an editor action handler (avoids RawText API variance).
|
||||
* Reindents pasted blocks when caret is in leading whitespace and the setting is enabled.
|
||||
*/
|
||||
class LyngPasteHandler : EditorWriteActionHandler(true) {
|
||||
private val log = com.intellij.openapi.diagnostic.Logger.getInstance(LyngPasteHandler::class.java)
|
||||
override fun doExecute(editor: Editor, caret: Caret?, dataContext: DataContext) {
|
||||
val project = editor.project
|
||||
if (project == null) return
|
||||
|
||||
val psiDocMgr = PsiDocumentManager.getInstance(project)
|
||||
val file = psiDocMgr.getPsiFile(editor.document)
|
||||
if (file == null || file.language != LyngLanguage) {
|
||||
pasteAsIs(editor)
|
||||
return
|
||||
}
|
||||
|
||||
val settings = LyngFormatterSettings.getInstance(project)
|
||||
if (!settings.reindentPastedBlocks) {
|
||||
pasteAsIs(editor)
|
||||
return
|
||||
}
|
||||
|
||||
val text = CopyPasteManager.getInstance().getContents<String>(DataFlavor.stringFlavor)
|
||||
if (text == null) {
|
||||
pasteAsIs(editor)
|
||||
return
|
||||
}
|
||||
|
||||
val caretModel = editor.caretModel
|
||||
val effectiveCaret = caret ?: caretModel.currentCaret
|
||||
val doc = editor.document
|
||||
PsiDocumentManager.getInstance(project).commitDocument(doc)
|
||||
|
||||
// Paste the text as-is first, then compute the inserted range and reindent that slice
|
||||
val options = CodeStyle.getIndentOptions(project, doc)
|
||||
val cfg = LyngFormatConfig(
|
||||
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
|
||||
useTabs = options.USE_TAB_CHARACTER,
|
||||
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
|
||||
)
|
||||
|
||||
// Replace selection (if any) or insert at caret with original clipboard text
|
||||
val selModel = editor.selectionModel
|
||||
val replaceStart = if (selModel.hasSelection()) selModel.selectionStart else effectiveCaret.offset
|
||||
val replaceEnd = if (selModel.hasSelection()) selModel.selectionEnd else effectiveCaret.offset
|
||||
|
||||
WriteCommandAction.runWriteCommandAction(project) {
|
||||
log.info("[LyngPaste] handler invoked for Lyng file; setting ON=${settings.reindentPastedBlocks}")
|
||||
// Step 1: paste as-is
|
||||
val beforeLen = doc.textLength
|
||||
doc.replaceString(replaceStart, replaceEnd, text)
|
||||
psiDocMgr.commitDocument(doc)
|
||||
|
||||
// Step 2: compute the freshly inserted range robustly (account for line-separator normalization)
|
||||
val insertedStart = replaceStart
|
||||
val delta = doc.textLength - beforeLen + (replaceEnd - replaceStart)
|
||||
val insertedEndExclusive = (insertedStart + delta).coerceIn(insertedStart, doc.textLength)
|
||||
|
||||
// Expand to full lines to let the formatter compute proper base/closing alignment
|
||||
val lineStart = run {
|
||||
var i = (insertedStart - 1).coerceAtLeast(0)
|
||||
while (i >= 0 && doc.charsSequence[i] != '\n') i--
|
||||
i + 1
|
||||
}
|
||||
var lineEndInclusive = run {
|
||||
var i = insertedEndExclusive
|
||||
val seq = doc.charsSequence
|
||||
while (i < seq.length && seq[i] != '\n') i++
|
||||
// include trailing newline if present
|
||||
if (i < seq.length && seq[i] == '\n') i + 1 else i
|
||||
}
|
||||
|
||||
// If the next non-whitespace char right after the insertion is a closing brace '}',
|
||||
// include that brace line into the formatting slice for better block alignment.
|
||||
run {
|
||||
val seq = doc.charsSequence
|
||||
var j = insertedEndExclusive
|
||||
while (j < seq.length && (seq[j] == ' ' || seq[j] == '\t' || seq[j] == '\n' || seq[j] == '\r')) j++
|
||||
if (j < seq.length && seq[j] == '}') {
|
||||
var k = j
|
||||
while (k < seq.length && seq[k] != '\n') k++
|
||||
lineEndInclusive = if (k < seq.length && seq[k] == '\n') k + 1 else k
|
||||
}
|
||||
}
|
||||
|
||||
val fullTextBefore = doc.text
|
||||
val expandedRange = (lineStart until lineEndInclusive)
|
||||
log.info("[LyngPaste] inserted=[$insertedStart,$insertedEndExclusive) expanded=[$lineStart,$lineEndInclusive)")
|
||||
val updatedFull = LyngFormatter.reindentRange(
|
||||
fullTextBefore,
|
||||
expandedRange,
|
||||
cfg,
|
||||
preserveBaseIndent = true,
|
||||
baseIndentFrom = insertedStart
|
||||
)
|
||||
|
||||
if (updatedFull != fullTextBefore) {
|
||||
val delta = updatedFull.length - fullTextBefore.length
|
||||
doc.replaceString(0, doc.textLength, updatedFull)
|
||||
psiDocMgr.commitDocument(doc)
|
||||
caretModel.moveToOffset((insertedEndExclusive + delta).coerceIn(0, doc.textLength))
|
||||
log.info("[LyngPaste] applied reindent to expanded range")
|
||||
} else {
|
||||
// No changes after reindent — just move caret to end of the inserted text
|
||||
caretModel.moveToOffset(insertedEndExclusive)
|
||||
log.info("[LyngPaste] no changes after reindent")
|
||||
}
|
||||
selModel.removeSelection()
|
||||
}
|
||||
}
|
||||
|
||||
private fun pasteAsIs(editor: Editor) {
|
||||
val text = CopyPasteManager.getInstance().getContents<String>(DataFlavor.stringFlavor) ?: return
|
||||
pasteText(editor, text)
|
||||
}
|
||||
|
||||
private fun pasteText(editor: Editor, text: String) {
|
||||
val project = editor.project ?: return
|
||||
val doc = editor.document
|
||||
val caretModel = editor.caretModel
|
||||
val selModel = editor.selectionModel
|
||||
WriteCommandAction.runWriteCommandAction(project) {
|
||||
val replaceStart = if (selModel.hasSelection()) selModel.selectionStart else caretModel.offset
|
||||
val replaceEnd = if (selModel.hasSelection()) selModel.selectionEnd else caretModel.offset
|
||||
doc.replaceString(replaceStart, replaceEnd, text)
|
||||
PsiDocumentManager.getInstance(project).commitDocument(doc)
|
||||
caretModel.moveToOffset(replaceStart + text.length)
|
||||
selModel.removeSelection()
|
||||
}
|
||||
}
|
||||
|
||||
// no longer used
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.editor
|
||||
|
||||
import com.intellij.application.options.CodeStyle
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.psi.PsiDocumentManager
|
||||
import com.intellij.psi.PsiFile
|
||||
import net.sergeych.lyng.format.LyngFormatConfig
|
||||
import net.sergeych.lyng.format.LyngFormatter
|
||||
import net.sergeych.lyng.idea.LyngLanguage
|
||||
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
|
||||
|
||||
/**
|
||||
* Helper for preparing reindented pasted text. EP wiring is deferred until API
|
||||
* signature is finalized for the target IDE build.
|
||||
*/
|
||||
object LyngPastePreProcessor {
|
||||
fun reindentForPaste(
|
||||
project: Project,
|
||||
editor: Editor,
|
||||
file: PsiFile,
|
||||
text: String
|
||||
): String {
|
||||
if (file.language != LyngLanguage) return text
|
||||
val settings = LyngFormatterSettings.getInstance(project)
|
||||
if (!settings.reindentPastedBlocks) return text
|
||||
|
||||
val doc = editor.document
|
||||
PsiDocumentManager.getInstance(project).commitDocument(doc)
|
||||
val options = CodeStyle.getIndentOptions(project, doc)
|
||||
val cfg = LyngFormatConfig(
|
||||
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
|
||||
useTabs = options.USE_TAB_CHARACTER,
|
||||
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
|
||||
)
|
||||
|
||||
// Only apply smart paste when caret is in leading whitespace position of its line
|
||||
val caret = editor.caretModel.currentCaret
|
||||
val line = doc.getLineNumber(caret.offset.coerceIn(0, doc.textLength))
|
||||
if (line < 0 || line >= doc.lineCount) return text
|
||||
val lineStart = doc.getLineStartOffset(line)
|
||||
val firstNonWs = firstNonWhitespace(doc, lineStart, doc.getLineEndOffset(line))
|
||||
if (caret.offset > firstNonWs) return text
|
||||
|
||||
val baseIndent = doc.charsSequence.subSequence(lineStart, caret.offset).toString()
|
||||
val reindented = LyngFormatter.reindent(text, cfg)
|
||||
// Prefix each non-empty line with base indent to preserve surrounding indentation
|
||||
val lines = reindented.split('\n')
|
||||
val sb = StringBuilder(reindented.length + lines.size * baseIndent.length)
|
||||
for ((idx, ln) in lines.withIndex()) {
|
||||
if (ln.isNotEmpty()) sb.append(baseIndent).append(ln) else sb.append(ln)
|
||||
if (idx < lines.lastIndex) sb.append('\n')
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun firstNonWhitespace(doc: com.intellij.openapi.editor.Document, from: Int, to: Int): Int {
|
||||
val seq = doc.charsSequence
|
||||
var i = from
|
||||
while (i < to) {
|
||||
val ch = seq[i]
|
||||
if (ch != ' ' && ch != '\t') break
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.editor
|
||||
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.psi.PsiDocumentManager
|
||||
import com.intellij.psi.PsiFile
|
||||
import net.sergeych.lyng.idea.LyngLanguage
|
||||
import net.sergeych.lyng.idea.settings.LyngFormatterSettings
|
||||
|
||||
/**
|
||||
* Smart Paste helper for Lyng. Not registered as EP yet to keep build stable across IDE SDKs.
|
||||
* Use `processOnPasteIfEnabled` from a CopyPastePreProcessor adapter once API signature is finalized.
|
||||
*/
|
||||
object LyngSmartPastePreProcessorHelper {
|
||||
fun processOnPasteIfEnabled(project: Project, file: PsiFile, editor: Editor, text: String): String {
|
||||
if (file.language != LyngLanguage) return text
|
||||
val settings = LyngFormatterSettings.getInstance(project)
|
||||
if (!settings.reindentPastedBlocks) return text
|
||||
PsiDocumentManager.getInstance(project).commitDocument(editor.document)
|
||||
return LyngPastePreProcessor.reindentForPaste(project, editor, file, text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.format
|
||||
|
||||
import com.intellij.formatting.*
|
||||
import com.intellij.lang.ASTNode
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.codeStyle.CodeStyleSettings
|
||||
|
||||
/**
|
||||
* Minimal formatting model: enables Reformat Code to at least re-apply indentation via LineIndentProvider
|
||||
* and normalize whitespace. We don’t implement a full PSI-based tree yet; this block treats the whole file
|
||||
* as a single formatting region and lets platform query line indents.
|
||||
*/
|
||||
class LyngFormattingModelBuilder : FormattingModelBuilder {
|
||||
override fun createModel(element: PsiElement, settings: CodeStyleSettings): FormattingModel {
|
||||
val file = element.containingFile
|
||||
val rootBlock = LineBlocksRootBlock(file, settings)
|
||||
return FormattingModelProvider.createFormattingModelForPsiFile(file, rootBlock, settings)
|
||||
}
|
||||
|
||||
override fun getRangeAffectingIndent(file: PsiFile, offset: Int, elementAtOffset: ASTNode?): TextRange? = null
|
||||
}
|
||||
|
||||
private class LineBlocksRootBlock(
|
||||
private val file: PsiFile,
|
||||
private val settings: CodeStyleSettings
|
||||
) : Block {
|
||||
override fun getTextRange(): TextRange = file.textRange
|
||||
|
||||
override fun getSubBlocks(): List<Block> = emptyList()
|
||||
|
||||
override fun getWrap(): Wrap? = null
|
||||
override fun getIndent(): Indent? = Indent.getNoneIndent()
|
||||
override fun getAlignment(): Alignment? = null
|
||||
override fun getSpacing(child1: Block?, child2: Block): Spacing? = null
|
||||
override fun getChildAttributes(newChildIndex: Int): ChildAttributes = ChildAttributes(Indent.getNoneIndent(), null)
|
||||
override fun isIncomplete(): Boolean = false
|
||||
override fun isLeaf(): Boolean = false
|
||||
}
|
||||
|
||||
// Intentionally no sub-blocks/spacing: indentation is handled by PreFormatProcessor + LineIndentProvider
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.format
|
||||
|
||||
import com.intellij.application.options.CodeStyle
|
||||
import com.intellij.lang.Language
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.psi.PsiDocumentManager
|
||||
import com.intellij.psi.codeStyle.CommonCodeStyleSettings.IndentOptions
|
||||
import com.intellij.psi.codeStyle.lineIndent.LineIndentProvider
|
||||
import net.sergeych.lyng.format.LyngFormatConfig
|
||||
import net.sergeych.lyng.format.LyngFormatter
|
||||
import net.sergeych.lyng.idea.LyngLanguage
|
||||
|
||||
/**
|
||||
* Lightweight indentation provider for Lyng.
|
||||
*
|
||||
* Rules (heuristic, text-based):
|
||||
* - New lines after an opening brace/paren increase indent level.
|
||||
* - Lines starting with a closing brace/paren decrease indent level by one.
|
||||
* - Keeps previous non-empty line's indent as baseline otherwise.
|
||||
*/
|
||||
class LyngLineIndentProvider : LineIndentProvider {
|
||||
override fun getLineIndent(project: com.intellij.openapi.project.Project, editor: Editor, language: Language?, offset: Int): String? {
|
||||
if (language != null && language != LyngLanguage) return null
|
||||
val doc = editor.document
|
||||
PsiDocumentManager.getInstance(project).commitDocument(doc)
|
||||
|
||||
val options = CodeStyle.getIndentOptions(project, doc)
|
||||
|
||||
val line = doc.getLineNumberSafe(offset)
|
||||
val indent = computeDesiredIndentFromCore(doc, line, options)
|
||||
return indent
|
||||
}
|
||||
|
||||
override fun isSuitableFor(language: Language?): Boolean = language == null || language == LyngLanguage
|
||||
|
||||
private fun Document.getLineNumberSafe(offset: Int): Int =
|
||||
getLineNumber(offset.coerceIn(0, textLength))
|
||||
|
||||
private fun Document.getLineText(line: Int): String {
|
||||
if (line < 0 || line >= lineCount) return ""
|
||||
val start = getLineStartOffset(line)
|
||||
val end = getLineEndOffset(line)
|
||||
return getText(TextRange(start, end))
|
||||
}
|
||||
|
||||
private fun indentUnit(options: IndentOptions): String =
|
||||
if (options.USE_TAB_CHARACTER) "\t" else " ".repeat(options.INDENT_SIZE.coerceAtLeast(1))
|
||||
|
||||
private fun indentOfLine(doc: Document, line: Int): String {
|
||||
val s = doc.getLineText(line)
|
||||
val i = s.indexOfFirst { !it.isWhitespace() }
|
||||
return if (i <= 0) s.takeWhile { it == ' ' || it == '\t' } else s.substring(0, i)
|
||||
}
|
||||
|
||||
private fun countIndentUnits(indent: String, options: IndentOptions): Int {
|
||||
if (indent.isEmpty()) return 0
|
||||
if (options.USE_TAB_CHARACTER) return indent.count { it == '\t' }
|
||||
val size = options.INDENT_SIZE.coerceAtLeast(1)
|
||||
var spaces = 0
|
||||
for (ch in indent) spaces += if (ch == '\t') size else 1
|
||||
return spaces / size
|
||||
}
|
||||
|
||||
private fun computeDesiredIndentFromCore(doc: Document, line: Int, options: IndentOptions): String {
|
||||
// Build a minimal text consisting of all previous lines and the current line.
|
||||
// Special case: when the current line is blank (newly created by Enter), compute the
|
||||
// indent as if there was a non-whitespace character at line start (append a sentinel).
|
||||
val start = 0
|
||||
val end = doc.getLineEndOffset(line)
|
||||
val snippet = doc.getText(TextRange(start, end))
|
||||
val isBlankLine = doc.getLineText(line).trim().isEmpty()
|
||||
val snippetForCalc = if (isBlankLine) snippet + "x" else snippet
|
||||
val cfg = LyngFormatConfig(
|
||||
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
|
||||
useTabs = options.USE_TAB_CHARACTER,
|
||||
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
|
||||
)
|
||||
val formatted = LyngFormatter.reindent(snippetForCalc, cfg)
|
||||
// Grab the last line's leading whitespace as the indent for the current line
|
||||
val lastNl = formatted.lastIndexOf('\n')
|
||||
val lastLine = if (lastNl >= 0) formatted.substring(lastNl + 1) else formatted
|
||||
val wsLen = lastLine.indexOfFirst { it != ' ' && it != '\t' }.let { if (it < 0) lastLine.length else it }
|
||||
return lastLine.substring(0, wsLen)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.format
|
||||
|
||||
// Placeholder: we planned a post-format processor fallback, but the 2024.3 platform
|
||||
// does not expose the older PostFormatProcessor API in our current dependency set.
|
||||
// Reformat Code will use the registered lang.formatter + LineIndentProvider.
|
||||
internal object LyngPostFormatProcessorPlaceholder
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.format
|
||||
|
||||
import com.intellij.application.options.CodeStyle
|
||||
import com.intellij.lang.ASTNode
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.TextRange
|
||||
import com.intellij.psi.codeStyle.CodeStyleManager
|
||||
import com.intellij.psi.impl.source.codeStyle.PreFormatProcessor
|
||||
import net.sergeych.lyng.format.LyngFormatConfig
|
||||
import net.sergeych.lyng.format.LyngFormatter
|
||||
import net.sergeych.lyng.idea.LyngLanguage
|
||||
|
||||
/**
|
||||
* Idempotent indentation fixer executed by Reformat Code before formatting.
|
||||
* It walks all lines in the affected range and applies exact indentation using
|
||||
* CodeStyleManager.adjustLineIndent(), which delegates to our LineIndentProvider.
|
||||
*/
|
||||
class LyngPreFormatProcessor : PreFormatProcessor {
|
||||
override fun process(element: ASTNode, range: TextRange): TextRange {
|
||||
val file = element.psi?.containingFile ?: return range
|
||||
if (file.language != LyngLanguage) return range
|
||||
val project: Project = file.project
|
||||
val doc = file.viewProvider.document ?: return range
|
||||
val psiDoc = com.intellij.psi.PsiDocumentManager.getInstance(project)
|
||||
val options = CodeStyle.getIndentOptions(project, doc)
|
||||
|
||||
val settings = net.sergeych.lyng.idea.settings.LyngFormatterSettings.getInstance(project)
|
||||
// When both spacing and wrapping are OFF, still fix indentation for the whole file to
|
||||
// guarantee visible changes on Reformat Code.
|
||||
val runFullFileIndent = !settings.enableSpacing && !settings.enableWrapping
|
||||
// Maintain a working range and a modification flag to avoid stale offsets after replacements
|
||||
var modified = false
|
||||
fun fullRange(): TextRange = TextRange(0, doc.textLength)
|
||||
var workingRange: TextRange = range.intersection(fullRange()) ?: fullRange()
|
||||
|
||||
val startLine = if (runFullFileIndent) 0 else doc.getLineNumber(workingRange.startOffset)
|
||||
val endLine = if (runFullFileIndent) (doc.lineCount - 1).coerceAtLeast(0)
|
||||
else doc.getLineNumber(workingRange.endOffset.coerceAtMost(doc.textLength))
|
||||
|
||||
fun codePart(s: String): String {
|
||||
val idx = s.indexOf("//")
|
||||
return if (idx >= 0) s.substring(0, idx) else s
|
||||
}
|
||||
|
||||
// Pre-scan to compute balances up to startLine
|
||||
var blockLevel = 0
|
||||
var parenBalance = 0
|
||||
var bracketBalance = 0
|
||||
for (ln in 0 until startLine) {
|
||||
val text = doc.getText(TextRange(doc.getLineStartOffset(ln), doc.getLineEndOffset(ln)))
|
||||
for (ch in codePart(text)) when (ch) {
|
||||
'{' -> blockLevel++
|
||||
'}' -> if (blockLevel > 0) blockLevel--
|
||||
'(' -> parenBalance++
|
||||
')' -> if (parenBalance > 0) parenBalance--
|
||||
'[' -> bracketBalance++
|
||||
']' -> if (bracketBalance > 0) bracketBalance--
|
||||
}
|
||||
}
|
||||
|
||||
// Re-indent each line deterministically (idempotent). We avoid any content
|
||||
// rewriting here to prevent long-running passes or re-entrant formatting.
|
||||
for (line in startLine..endLine) {
|
||||
val lineStart = doc.getLineStartOffset(line)
|
||||
// adjustLineIndent delegates to our LineIndentProvider which computes
|
||||
// indentation from scratch; this is safe and idempotent
|
||||
CodeStyleManager.getInstance(project).adjustLineIndent(file, lineStart)
|
||||
|
||||
// After indentation, update block/paren/bracket balances using the current line text
|
||||
val lineEnd = doc.getLineEndOffset(line)
|
||||
val text = doc.getText(TextRange(lineStart, lineEnd))
|
||||
val code = codePart(text)
|
||||
for (ch in code) when (ch) {
|
||||
'{' -> blockLevel++
|
||||
'}' -> if (blockLevel > 0) blockLevel--
|
||||
'(' -> parenBalance++
|
||||
')' -> if (parenBalance > 0) parenBalance--
|
||||
'[' -> bracketBalance++
|
||||
']' -> if (bracketBalance > 0) bracketBalance--
|
||||
}
|
||||
}
|
||||
// If both spacing and wrapping are OFF, explicitly reindent the text using core formatter to
|
||||
// guarantee indentation is fixed even when the platform doesn't rewrite whitespace by itself.
|
||||
if (!settings.enableSpacing && !settings.enableWrapping) {
|
||||
val cfg = LyngFormatConfig(
|
||||
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
|
||||
useTabs = options.USE_TAB_CHARACTER,
|
||||
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
|
||||
)
|
||||
val full = fullRange()
|
||||
val r = if (runFullFileIndent) full else workingRange.intersection(full) ?: full
|
||||
val text = doc.getText(r)
|
||||
val formatted = LyngFormatter.reindent(text, cfg)
|
||||
if (formatted != text) {
|
||||
doc.replaceString(r.startOffset, r.endOffset, formatted)
|
||||
modified = true
|
||||
psiDoc.commitDocument(doc)
|
||||
workingRange = fullRange()
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally apply spacing using the core formatter if enabled in settings (wrapping stays off)
|
||||
if (settings.enableSpacing) {
|
||||
val cfg = LyngFormatConfig(
|
||||
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
|
||||
useTabs = options.USE_TAB_CHARACTER,
|
||||
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
|
||||
applySpacing = true,
|
||||
applyWrapping = false,
|
||||
)
|
||||
val safe = workingRange.intersection(fullRange()) ?: fullRange()
|
||||
val text = doc.getText(safe)
|
||||
val formatted = LyngFormatter.format(text, cfg)
|
||||
if (formatted != text) {
|
||||
doc.replaceString(safe.startOffset, safe.endOffset, formatted)
|
||||
modified = true
|
||||
psiDoc.commitDocument(doc)
|
||||
workingRange = fullRange()
|
||||
}
|
||||
}
|
||||
// Optionally apply wrapping (after spacing) when enabled
|
||||
if (settings.enableWrapping) {
|
||||
val cfg = LyngFormatConfig(
|
||||
indentSize = options.INDENT_SIZE.coerceAtLeast(1),
|
||||
useTabs = options.USE_TAB_CHARACTER,
|
||||
continuationIndentSize = options.CONTINUATION_INDENT_SIZE.coerceAtLeast(options.INDENT_SIZE.coerceAtLeast(1)),
|
||||
applySpacing = settings.enableSpacing,
|
||||
applyWrapping = true,
|
||||
)
|
||||
val safe2 = workingRange.intersection(fullRange()) ?: fullRange()
|
||||
val text2 = doc.getText(safe2)
|
||||
val wrapped = LyngFormatter.format(text2, cfg)
|
||||
if (wrapped != text2) {
|
||||
doc.replaceString(safe2.startOffset, safe2.endOffset, wrapped)
|
||||
modified = true
|
||||
psiDoc.commitDocument(doc)
|
||||
workingRange = fullRange()
|
||||
}
|
||||
}
|
||||
// Return a safe range for the formatter to continue with, preventing stale offsets
|
||||
return if (modified) fullRange() else (range.intersection(fullRange()) ?: fullRange())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.idea.highlight
|
||||
|
||||
import com.intellij.openapi.editor.colors.TextAttributesKey
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighter
|
||||
import com.intellij.openapi.options.colors.AttributesDescriptor
|
||||
import com.intellij.openapi.options.colors.ColorDescriptor
|
||||
import com.intellij.openapi.options.colors.ColorSettingsPage
|
||||
import javax.swing.Icon
|
||||
|
||||
class LyngColorSettingsPage : ColorSettingsPage {
|
||||
override fun getDisplayName(): String = "Lyng"
|
||||
|
||||
override fun getIcon(): Icon? = null
|
||||
|
||||
override fun getHighlighter(): SyntaxHighlighter = LyngSyntaxHighlighter()
|
||||
|
||||
override fun getDemoText(): String = """
|
||||
// Lyng demo
|
||||
import lyng.stdlib as std
|
||||
|
||||
class Sample {
|
||||
fun greet(name: String): String {
|
||||
val message = "Hello, " + name
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
var counter = 0
|
||||
counter = counter + 1
|
||||
""".trimIndent()
|
||||
|
||||
override fun getAdditionalHighlightingTagToDescriptorMap(): MutableMap<String, TextAttributesKey>? = null
|
||||
|
||||
override fun getAttributeDescriptors(): Array<AttributesDescriptor> = arrayOf(
|
||||
AttributesDescriptor("Keyword", LyngHighlighterColors.KEYWORD),
|
||||
AttributesDescriptor("String", LyngHighlighterColors.STRING),
|
||||
AttributesDescriptor("Number", LyngHighlighterColors.NUMBER),
|
||||
AttributesDescriptor("Line comment", LyngHighlighterColors.LINE_COMMENT),
|
||||
AttributesDescriptor("Block comment", LyngHighlighterColors.BLOCK_COMMENT),
|
||||
AttributesDescriptor("Identifier", LyngHighlighterColors.IDENTIFIER),
|
||||
AttributesDescriptor("Punctuation", LyngHighlighterColors.PUNCT),
|
||||
// Semantic
|
||||
AttributesDescriptor("Variable (semantic)", LyngHighlighterColors.VARIABLE),
|
||||
AttributesDescriptor("Value (semantic)", LyngHighlighterColors.VALUE),
|
||||
AttributesDescriptor("Function (semantic)", LyngHighlighterColors.FUNCTION),
|
||||
AttributesDescriptor("Type (semantic)", LyngHighlighterColors.TYPE),
|
||||
AttributesDescriptor("Namespace (semantic)", LyngHighlighterColors.NAMESPACE),
|
||||
AttributesDescriptor("Parameter (semantic)", LyngHighlighterColors.PARAMETER),
|
||||
)
|
||||
|
||||
override fun getColorDescriptors(): Array<ColorDescriptor> = ColorDescriptor.EMPTY_ARRAY
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
/*
|
||||
* Text attribute keys for Lyng token and semantic highlighting
|
||||
*/
|
||||
package net.sergeych.lyng.idea.highlight
|
||||
|
||||
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
|
||||
import com.intellij.openapi.editor.colors.TextAttributesKey
|
||||
|
||||
object LyngHighlighterColors {
|
||||
val KEYWORD: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
|
||||
"LYNG_KEYWORD", DefaultLanguageHighlighterColors.KEYWORD
|
||||
)
|
||||
val STRING: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
|
||||
"LYNG_STRING", DefaultLanguageHighlighterColors.STRING
|
||||
)
|
||||
val NUMBER: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
|
||||
"LYNG_NUMBER", DefaultLanguageHighlighterColors.NUMBER
|
||||
)
|
||||
val LINE_COMMENT: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
|
||||
"LYNG_LINE_COMMENT", DefaultLanguageHighlighterColors.LINE_COMMENT
|
||||
)
|
||||
val BLOCK_COMMENT: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
|
||||
"LYNG_BLOCK_COMMENT", DefaultLanguageHighlighterColors.BLOCK_COMMENT
|
||||
)
|
||||
val IDENTIFIER: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
|
||||
"LYNG_IDENTIFIER", DefaultLanguageHighlighterColors.IDENTIFIER
|
||||
)
|
||||
val PUNCT: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
|
||||
"LYNG_PUNCT", DefaultLanguageHighlighterColors.DOT
|
||||
)
|
||||
|
||||
// Semantic layer keys (placeholders for now)
|
||||
val VARIABLE: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
|
||||
"LYNG_VARIABLE", DefaultLanguageHighlighterColors.LOCAL_VARIABLE
|
||||
)
|
||||
val VALUE: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
|
||||
"LYNG_VALUE", DefaultLanguageHighlighterColors.INSTANCE_FIELD
|
||||
)
|
||||
val FUNCTION: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
|
||||
"LYNG_FUNCTION", DefaultLanguageHighlighterColors.FUNCTION_CALL
|
||||
)
|
||||
val TYPE: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
|
||||
"LYNG_TYPE", DefaultLanguageHighlighterColors.CLASS_REFERENCE
|
||||
)
|
||||
val NAMESPACE: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
|
||||
"LYNG_NAMESPACE", DefaultLanguageHighlighterColors.PREDEFINED_SYMBOL
|
||||
)
|
||||
val PARAMETER: TextAttributesKey = TextAttributesKey.createTextAttributesKey(
|
||||
"LYNG_PARAMETER", DefaultLanguageHighlighterColors.PARAMETER
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
/*
|
||||
* Minimal hand-written lexer for Lyng token highlighting
|
||||
*/
|
||||
package net.sergeych.lyng.idea.highlight
|
||||
|
||||
import com.intellij.lexer.LexerBase
|
||||
import com.intellij.psi.tree.IElementType
|
||||
|
||||
class LyngLexer : LexerBase() {
|
||||
private var buffer: CharSequence = ""
|
||||
private var startOffset: Int = 0
|
||||
private var endOffset: Int = 0
|
||||
private var myTokenStart: Int = 0
|
||||
private var myTokenEnd: Int = 0
|
||||
private var myTokenType: IElementType? = null
|
||||
|
||||
private val keywords = setOf(
|
||||
"fun", "val", "var", "class", "type", "import", "as",
|
||||
"if", "else", "for", "while", "return", "true", "false", "null",
|
||||
"when", "in", "is", "break", "continue", "try", "catch", "finally"
|
||||
)
|
||||
|
||||
override fun start(buffer: CharSequence, startOffset: Int, endOffset: Int, initialState: Int) {
|
||||
this.buffer = buffer
|
||||
this.startOffset = startOffset
|
||||
this.endOffset = endOffset
|
||||
this.myTokenStart = startOffset
|
||||
this.myTokenEnd = startOffset
|
||||
this.myTokenType = null
|
||||
advance()
|
||||
}
|
||||
|
||||
override fun getState(): Int = 0
|
||||
|
||||
override fun getTokenType(): IElementType? = myTokenType
|
||||
|
||||
override fun getTokenStart(): Int = myTokenStart
|
||||
|
||||
override fun getTokenEnd(): Int = myTokenEnd
|
||||
|
||||
override fun getBufferSequence(): CharSequence = buffer
|
||||
|
||||
override fun getBufferEnd(): Int = endOffset
|
||||
|
||||
override fun advance() {
|
||||
if (myTokenEnd >= endOffset) {
|
||||
myTokenType = null
|
||||
return
|
||||
}
|
||||
var i = if (myTokenEnd == 0) startOffset else myTokenEnd
|
||||
// Skip nothing; set start
|
||||
myTokenStart = i
|
||||
if (i >= endOffset) { myTokenType = null; return }
|
||||
|
||||
val ch = buffer[i]
|
||||
|
||||
// Whitespace
|
||||
if (ch.isWhitespace()) {
|
||||
i++
|
||||
while (i < endOffset && buffer[i].isWhitespace()) i++
|
||||
myTokenEnd = i
|
||||
myTokenType = LyngTokenTypes.WHITESPACE
|
||||
return
|
||||
}
|
||||
|
||||
// Line comment //...
|
||||
if (ch == '/' && i + 1 < endOffset && buffer[i + 1] == '/') {
|
||||
i += 2
|
||||
while (i < endOffset && buffer[i] != '\n' && buffer[i] != '\r') i++
|
||||
myTokenEnd = i
|
||||
myTokenType = LyngTokenTypes.LINE_COMMENT
|
||||
return
|
||||
}
|
||||
|
||||
// Block comment /* ... */
|
||||
if (ch == '/' && i + 1 < endOffset && buffer[i + 1] == '*') {
|
||||
i += 2
|
||||
while (i + 1 < endOffset && !(buffer[i] == '*' && buffer[i + 1] == '/')) i++
|
||||
if (i + 1 < endOffset) i += 2 // consume */
|
||||
myTokenEnd = i
|
||||
myTokenType = LyngTokenTypes.BLOCK_COMMENT
|
||||
return
|
||||
}
|
||||
|
||||
// String "..." with simple escape handling
|
||||
if (ch == '"') {
|
||||
i++
|
||||
while (i < endOffset) {
|
||||
val c = buffer[i]
|
||||
if (c == '\\') { // escape
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (c == '"') { i++; break }
|
||||
i++
|
||||
}
|
||||
myTokenEnd = i
|
||||
myTokenType = LyngTokenTypes.STRING
|
||||
return
|
||||
}
|
||||
|
||||
// Number
|
||||
if (ch.isDigit()) {
|
||||
i++
|
||||
var hasDot = false
|
||||
while (i < endOffset) {
|
||||
val c = buffer[i]
|
||||
if (c.isDigit()) { i++; continue }
|
||||
if (c == '.' && !hasDot) { hasDot = true; i++; continue }
|
||||
break
|
||||
}
|
||||
myTokenEnd = i
|
||||
myTokenType = LyngTokenTypes.NUMBER
|
||||
return
|
||||
}
|
||||
|
||||
// Identifier / keyword
|
||||
if (ch.isIdentifierStart()) {
|
||||
i++
|
||||
while (i < endOffset && buffer[i].isIdentifierPart()) i++
|
||||
myTokenEnd = i
|
||||
val text = buffer.subSequence(myTokenStart, myTokenEnd).toString()
|
||||
myTokenType = if (text in keywords) LyngTokenTypes.KEYWORD else LyngTokenTypes.IDENTIFIER
|
||||
return
|
||||
}
|
||||
|
||||
// Punctuation
|
||||
if (isPunct(ch)) {
|
||||
i++
|
||||
myTokenEnd = i
|
||||
myTokenType = LyngTokenTypes.PUNCT
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback bad char
|
||||
myTokenEnd = i + 1
|
||||
myTokenType = LyngTokenTypes.BAD_CHAR
|
||||
}
|
||||
|
||||
private fun Char.isWhitespace(): Boolean = this == ' ' || this == '\t' || this == '\n' || this == '\r' || this == '\u000C'
|
||||
private fun Char.isDigit(): Boolean = this in '0'..'9'
|
||||
private fun Char.isIdentifierStart(): Boolean = this == '_' || this.isLetter()
|
||||
private fun Char.isIdentifierPart(): Boolean = this.isIdentifierStart() || this.isDigit()
|
||||
private fun isPunct(c: Char): Boolean = c in setOf('(', ')', '{', '}', '[', ']', '.', ',', ';', ':', '+', '-', '*', '/', '%', '=', '<', '>', '!', '?', '&', '|', '^', '~')
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.idea.highlight
|
||||
|
||||
import com.intellij.lexer.Lexer
|
||||
import com.intellij.openapi.editor.colors.TextAttributesKey
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighter
|
||||
import com.intellij.psi.tree.IElementType
|
||||
|
||||
class LyngSyntaxHighlighter : SyntaxHighlighter {
|
||||
override fun getHighlightingLexer(): Lexer = LyngLexer()
|
||||
|
||||
override fun getTokenHighlights(tokenType: IElementType): Array<TextAttributesKey> = when (tokenType) {
|
||||
LyngTokenTypes.KEYWORD -> pack(LyngHighlighterColors.KEYWORD)
|
||||
LyngTokenTypes.STRING -> pack(LyngHighlighterColors.STRING)
|
||||
LyngTokenTypes.NUMBER -> pack(LyngHighlighterColors.NUMBER)
|
||||
LyngTokenTypes.LINE_COMMENT -> pack(LyngHighlighterColors.LINE_COMMENT)
|
||||
LyngTokenTypes.BLOCK_COMMENT -> pack(LyngHighlighterColors.BLOCK_COMMENT)
|
||||
LyngTokenTypes.PUNCT -> pack(LyngHighlighterColors.PUNCT)
|
||||
LyngTokenTypes.IDENTIFIER -> pack(LyngHighlighterColors.IDENTIFIER)
|
||||
else -> emptyArray()
|
||||
}
|
||||
|
||||
private fun pack(vararg keys: TextAttributesKey): Array<TextAttributesKey> = arrayOf(*keys)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package net.sergeych.lyng.idea.highlight
|
||||
|
||||
import com.intellij.openapi.fileTypes.SingleLazyInstanceSyntaxHighlighterFactory
|
||||
import com.intellij.openapi.fileTypes.SyntaxHighlighter
|
||||
|
||||
class LyngSyntaxHighlighterFactory : SingleLazyInstanceSyntaxHighlighterFactory() {
|
||||
override fun createHighlighter(): SyntaxHighlighter = LyngSyntaxHighlighter()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.highlight
|
||||
|
||||
import com.intellij.psi.tree.IElementType
|
||||
import net.sergeych.lyng.idea.LyngLanguage
|
||||
|
||||
class LyngTokenType(debugName: String) : IElementType(debugName, LyngLanguage)
|
||||
|
||||
object LyngTokenTypes {
|
||||
val WHITESPACE = LyngTokenType("WHITESPACE")
|
||||
val LINE_COMMENT = LyngTokenType("LINE_COMMENT")
|
||||
val BLOCK_COMMENT = LyngTokenType("BLOCK_COMMENT")
|
||||
val STRING = LyngTokenType("STRING")
|
||||
val NUMBER = LyngTokenType("NUMBER")
|
||||
val KEYWORD = LyngTokenType("KEYWORD")
|
||||
val IDENTIFIER = LyngTokenType("IDENTIFIER")
|
||||
val PUNCT = LyngTokenType("PUNCT")
|
||||
val BAD_CHAR = LyngTokenType("BAD_CHAR")
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.psi
|
||||
|
||||
import com.intellij.extapi.psi.PsiFileBase
|
||||
import com.intellij.openapi.fileTypes.FileType
|
||||
import com.intellij.psi.FileViewProvider
|
||||
import net.sergeych.lyng.idea.LyngFileType
|
||||
import net.sergeych.lyng.idea.LyngLanguage
|
||||
|
||||
class LyngFile(viewProvider: FileViewProvider) : PsiFileBase(viewProvider, LyngLanguage) {
|
||||
override fun getFileType(): FileType = LyngFileType
|
||||
override fun toString(): String = "Lyng File"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.psi
|
||||
|
||||
import com.intellij.extapi.psi.ASTWrapperPsiElement
|
||||
import com.intellij.lang.ASTNode
|
||||
import com.intellij.lang.ParserDefinition
|
||||
import com.intellij.lang.PsiBuilder
|
||||
import com.intellij.lang.PsiParser
|
||||
import com.intellij.lexer.Lexer
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.psi.FileViewProvider
|
||||
import com.intellij.psi.PsiElement
|
||||
import com.intellij.psi.PsiFile
|
||||
import com.intellij.psi.TokenType
|
||||
import com.intellij.psi.tree.IFileElementType
|
||||
import com.intellij.psi.tree.TokenSet
|
||||
import net.sergeych.lyng.idea.LyngLanguage
|
||||
import net.sergeych.lyng.idea.highlight.LyngLexer
|
||||
import net.sergeych.lyng.idea.highlight.LyngTokenTypes
|
||||
|
||||
class LyngParserDefinition : ParserDefinition {
|
||||
companion object {
|
||||
val FILE: IFileElementType = IFileElementType(LyngLanguage)
|
||||
private val WHITE_SPACES: TokenSet = TokenSet.create(LyngTokenTypes.WHITESPACE, TokenType.WHITE_SPACE)
|
||||
private val COMMENTS: TokenSet = TokenSet.create(LyngTokenTypes.LINE_COMMENT, LyngTokenTypes.BLOCK_COMMENT)
|
||||
private val STRINGS: TokenSet = TokenSet.create(LyngTokenTypes.STRING)
|
||||
}
|
||||
|
||||
override fun createLexer(project: Project?): Lexer = LyngLexer()
|
||||
|
||||
override fun createParser(project: Project?): PsiParser = PsiParser { root, builder ->
|
||||
val mark: PsiBuilder.Marker = builder.mark()
|
||||
while (!builder.eof()) builder.advanceLexer()
|
||||
mark.done(root)
|
||||
builder.treeBuilt
|
||||
}
|
||||
|
||||
override fun getFileNodeType(): IFileElementType = FILE
|
||||
|
||||
override fun getWhitespaceTokens(): TokenSet = WHITE_SPACES
|
||||
|
||||
override fun getCommentTokens(): TokenSet = COMMENTS
|
||||
|
||||
override fun getStringLiteralElements(): TokenSet = STRINGS
|
||||
|
||||
override fun createElement(node: ASTNode): PsiElement = ASTWrapperPsiElement(node)
|
||||
|
||||
override fun createFile(viewProvider: FileViewProvider): PsiFile = LyngFile(viewProvider)
|
||||
|
||||
override fun spaceExistenceTypeBetweenTokens(left: ASTNode, right: ASTNode): ParserDefinition.SpaceRequirements =
|
||||
ParserDefinition.SpaceRequirements.MAY
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.settings
|
||||
|
||||
import com.intellij.openapi.components.PersistentStateComponent
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.State
|
||||
import com.intellij.openapi.components.Storage
|
||||
import com.intellij.openapi.project.Project
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
@State(name = "LyngFormatterSettings", storages = [Storage("lyng_idea.xml")])
|
||||
class LyngFormatterSettings(private val project: Project) : PersistentStateComponent<LyngFormatterSettings.State> {
|
||||
|
||||
data class State(
|
||||
var enableSpacing: Boolean = false,
|
||||
var enableWrapping: Boolean = false,
|
||||
var reindentClosedBlockOnEnter: Boolean = true,
|
||||
var reindentPastedBlocks: Boolean = true,
|
||||
var normalizeBlockCommentIndent: Boolean = false,
|
||||
)
|
||||
|
||||
private var myState: State = State()
|
||||
|
||||
override fun getState(): State = myState
|
||||
|
||||
override fun loadState(state: State) {
|
||||
myState = state
|
||||
}
|
||||
|
||||
var enableSpacing: Boolean
|
||||
get() = myState.enableSpacing
|
||||
set(value) { myState.enableSpacing = value }
|
||||
|
||||
var enableWrapping: Boolean
|
||||
get() = myState.enableWrapping
|
||||
set(value) { myState.enableWrapping = value }
|
||||
|
||||
var reindentClosedBlockOnEnter: Boolean
|
||||
get() = myState.reindentClosedBlockOnEnter
|
||||
set(value) { myState.reindentClosedBlockOnEnter = value }
|
||||
|
||||
var reindentPastedBlocks: Boolean
|
||||
get() = myState.reindentPastedBlocks
|
||||
set(value) { myState.reindentPastedBlocks = value }
|
||||
|
||||
var normalizeBlockCommentIndent: Boolean
|
||||
get() = myState.normalizeBlockCommentIndent
|
||||
set(value) { myState.normalizeBlockCommentIndent = value }
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun getInstance(project: Project): LyngFormatterSettings = project.getService(LyngFormatterSettings::class.java)
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.settings
|
||||
|
||||
import com.intellij.openapi.options.Configurable
|
||||
import com.intellij.openapi.project.Project
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.JCheckBox
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
|
||||
class LyngFormatterSettingsConfigurable(private val project: Project) : Configurable {
|
||||
private var panel: JPanel? = null
|
||||
private var spacingCb: JCheckBox? = null
|
||||
private var wrappingCb: JCheckBox? = null
|
||||
private var reindentClosedBlockCb: JCheckBox? = null
|
||||
private var reindentPasteCb: JCheckBox? = null
|
||||
private var normalizeBlockCommentIndentCb: JCheckBox? = null
|
||||
|
||||
override fun getDisplayName(): String = "Lyng Formatter"
|
||||
|
||||
override fun createComponent(): JComponent {
|
||||
val p = JPanel()
|
||||
p.layout = BoxLayout(p, BoxLayout.Y_AXIS)
|
||||
spacingCb = JCheckBox("Enable spacing normalization (commas/operators/colons/keyword parens)")
|
||||
wrappingCb = JCheckBox("Enable line wrapping (120 cols) [experimental]")
|
||||
reindentClosedBlockCb = JCheckBox("Reindent enclosed block on Enter after '}'")
|
||||
reindentPasteCb = JCheckBox("Reindent pasted blocks (align pasted code to current indent)")
|
||||
normalizeBlockCommentIndentCb = JCheckBox("Normalize block comment indentation [experimental]")
|
||||
|
||||
// Tooltips / short help
|
||||
spacingCb?.toolTipText = "Applies minimal, safe spacing (e.g., around commas/operators, control-flow parens)."
|
||||
wrappingCb?.toolTipText = "Experimental: wrap long argument lists to keep lines under ~120 columns."
|
||||
reindentClosedBlockCb?.toolTipText = "On Enter after a closing '}', reindent the just-closed {…} block using formatter rules."
|
||||
reindentPasteCb?.toolTipText = "When caret is in leading whitespace, reindent the pasted text and align it to the caret's indent."
|
||||
normalizeBlockCommentIndentCb?.toolTipText = "Experimental: normalize indentation inside /* … */ comments (code is not modified)."
|
||||
p.add(spacingCb)
|
||||
p.add(wrappingCb)
|
||||
p.add(reindentClosedBlockCb)
|
||||
p.add(reindentPasteCb)
|
||||
p.add(normalizeBlockCommentIndentCb)
|
||||
panel = p
|
||||
reset()
|
||||
return p
|
||||
}
|
||||
|
||||
override fun isModified(): Boolean {
|
||||
val s = LyngFormatterSettings.getInstance(project)
|
||||
return spacingCb?.isSelected != s.enableSpacing ||
|
||||
wrappingCb?.isSelected != s.enableWrapping ||
|
||||
reindentClosedBlockCb?.isSelected != s.reindentClosedBlockOnEnter ||
|
||||
reindentPasteCb?.isSelected != s.reindentPastedBlocks ||
|
||||
normalizeBlockCommentIndentCb?.isSelected != s.normalizeBlockCommentIndent
|
||||
}
|
||||
|
||||
override fun apply() {
|
||||
val s = LyngFormatterSettings.getInstance(project)
|
||||
s.enableSpacing = spacingCb?.isSelected == true
|
||||
s.enableWrapping = wrappingCb?.isSelected == true
|
||||
s.reindentClosedBlockOnEnter = reindentClosedBlockCb?.isSelected == true
|
||||
s.reindentPastedBlocks = reindentPasteCb?.isSelected == true
|
||||
s.normalizeBlockCommentIndent = normalizeBlockCommentIndentCb?.isSelected == true
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
val s = LyngFormatterSettings.getInstance(project)
|
||||
spacingCb?.isSelected = s.enableSpacing
|
||||
wrappingCb?.isSelected = s.enableWrapping
|
||||
reindentClosedBlockCb?.isSelected = s.reindentClosedBlockOnEnter
|
||||
reindentPasteCb?.isSelected = s.reindentPastedBlocks
|
||||
normalizeBlockCommentIndentCb?.isSelected = s.normalizeBlockCommentIndent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng.idea.util
|
||||
|
||||
import net.sergeych.lyng.ModuleScope
|
||||
import net.sergeych.lyng.Pos
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.Script
|
||||
import net.sergeych.lyng.pacman.ImportProvider
|
||||
|
||||
/**
|
||||
* Import provider for IDE background features that never throws on missing modules.
|
||||
* It allows all imports and returns an empty [ModuleScope] for unknown packages so
|
||||
* the compiler can still build MiniAst for Quick Docs / highlighting.
|
||||
*/
|
||||
class IdeLenientImportProvider private constructor(root: Scope) : ImportProvider(root) {
|
||||
override suspend fun createModuleScope(pos: Pos, packageName: String): ModuleScope = ModuleScope(this, pos, packageName)
|
||||
|
||||
companion object {
|
||||
/** Create a provider based on the default manager's root scope. */
|
||||
fun create(): IdeLenientImportProvider = IdeLenientImportProvider(Script.defaultImportManager.rootScope)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<!--
|
||||
~ Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License.
|
||||
~
|
||||
-->
|
||||
|
||||
<idea-plugin>
|
||||
<id>net.sergeych.lyng.idea</id>
|
||||
<name>Lyng Language Support</name>
|
||||
<vendor email="real.sergeych@gmail.com">Sergeych Works</vendor>
|
||||
|
||||
<description>
|
||||
<![CDATA[
|
||||
Basic Lyng language support: file type, syntax highlighting scaffold, and quick docs/highlighter stubs.
|
||||
]]>
|
||||
</description>
|
||||
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
<!-- Needed for editor language features (syntax highlighting, etc.) -->
|
||||
<depends>com.intellij.modules.lang</depends>
|
||||
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<!-- Language and file type -->
|
||||
<fileType implementationClass="net.sergeych.lyng.idea.LyngFileType" name="Lyng" extensions="lyng" fieldName="INSTANCE" language="Lyng"/>
|
||||
|
||||
<!-- Minimal parser/PSI to fully wire editor services for the language -->
|
||||
<lang.parserDefinition language="Lyng" implementationClass="net.sergeych.lyng.idea.psi.LyngParserDefinition"/>
|
||||
|
||||
<!-- Syntax highlighter: register under language EP -->
|
||||
<lang.syntaxHighlighterFactory language="Lyng" implementationClass="net.sergeych.lyng.idea.highlight.LyngSyntaxHighlighterFactory"/>
|
||||
|
||||
<!-- Color settings page -->
|
||||
<colorSettingsPage implementation="net.sergeych.lyng.idea.highlight.LyngColorSettingsPage"/>
|
||||
|
||||
<!-- External annotator for semantic highlighting -->
|
||||
<externalAnnotator language="Lyng" implementationClass="net.sergeych.lyng.idea.annotators.LyngExternalAnnotator"/>
|
||||
|
||||
<!-- Quick documentation provider bound to Lyng language -->
|
||||
<lang.documentationProvider language="Lyng" implementationClass="net.sergeych.lyng.idea.docs.LyngDocumentationProvider"/>
|
||||
|
||||
<!-- Comment toggling support -->
|
||||
<lang.commenter language="Lyng" implementationClass="net.sergeych.lyng.idea.comment.LyngCommenter"/>
|
||||
|
||||
<!-- Indentation provider to improve auto-indent and reformat behavior -->
|
||||
<lineIndentProvider implementation="net.sergeych.lyng.idea.format.LyngLineIndentProvider"/>
|
||||
|
||||
<!-- Formatting model so Reformat Code (Ctrl+Alt+L) applies indentation across the file -->
|
||||
<lang.formatter language="Lyng" implementationClass="net.sergeych.lyng.idea.format.LyngFormattingModelBuilder"/>
|
||||
|
||||
<!-- Ensure idempotent line indentation before formatting using our LineIndentProvider -->
|
||||
<preFormatProcessor implementation="net.sergeych.lyng.idea.format.LyngPreFormatProcessor"/>
|
||||
|
||||
<!-- Settings UI -->
|
||||
<projectConfigurable instance="net.sergeych.lyng.idea.settings.LyngFormatterSettingsConfigurable"
|
||||
displayName="Lyng Formatter"/>
|
||||
|
||||
<!-- Smart Enter handler -->
|
||||
<enterHandlerDelegate implementation="net.sergeych.lyng.idea.editor.LyngEnterHandler"/>
|
||||
|
||||
<!-- Smart Backspace handler (deferred) -->
|
||||
<!-- <backspaceHandlerDelegate implementation="net.sergeych.lyng.idea.editor.LyngBackspaceHandler"/> -->
|
||||
|
||||
<!-- Smart Paste via action handler (ensure our handler participates first) -->
|
||||
<editorActionHandler action="EditorPaste" order="first" implementationClass="net.sergeych.lyng.idea.editor.LyngPasteHandler"/>
|
||||
|
||||
<!-- If targeting SDKs with stable RawText API, the EP below can be enabled instead: -->
|
||||
<!-- <copyPastePreProcessor implementation="net.sergeych.lyng.idea.editor.LyngCopyPastePreProcessor"/> -->
|
||||
</extensions>
|
||||
|
||||
<actions/>
|
||||
</idea-plugin>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
- Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
-
|
||||
- Licensed under the Apache License, Version 2.0 (the "License");
|
||||
- you may not use this file except in compliance with the License.
|
||||
- You may obtain a copy of the License at
|
||||
-
|
||||
- http://www.apache.org/licenses/LICENSE-2.0
|
||||
-
|
||||
- Unless required by applicable law or agreed to in writing, software
|
||||
- distributed under the License is distributed on an "AS IS" BASIS,
|
||||
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
- See the License for the specific language governing permissions and
|
||||
- limitations under the License.
|
||||
-
|
||||
-->
|
||||
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg" aria-labelledby="title" role="img">
|
||||
<title>Lyng Plugin Icon (temporary λ)</title>
|
||||
<defs>
|
||||
<style>
|
||||
.glyph { fill: none; stroke: currentColor; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(2,2)">
|
||||
<path class="glyph" d="M12 6 L18 22 C19.2 25.5 22.2 28 26 28 L32 28"/>
|
||||
<path class="glyph" d="M18 22 L8 34"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
- Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com
|
||||
-
|
||||
- Licensed under the Apache License, Version 2.0 (the "License");
|
||||
- you may not use this file except in compliance with the License.
|
||||
- You may obtain a copy of the License at
|
||||
-
|
||||
- http://www.apache.org/licenses/LICENSE-2.0
|
||||
-
|
||||
- Unless required by applicable law or agreed to in writing, software
|
||||
- distributed under the License is distributed on an "AS IS" BASIS,
|
||||
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
- See the License for the specific language governing permissions and
|
||||
- limitations under the License.
|
||||
-
|
||||
-->
|
||||
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" aria-labelledby="title" role="img">
|
||||
<title>Lyng File Icon (temporary λ)</title>
|
||||
<defs>
|
||||
<style>
|
||||
.g { fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
||||
</style>
|
||||
</defs>
|
||||
<g transform="translate(0.5,0.5)">
|
||||
<path class="g" d="M4.5 2.5 L7 9.5 C7.6 11.2 9.0 12.5 11.0 12.5 L14.5 12.5"/>
|
||||
<path class="g" d="M7 9.5 L2.5 14.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
Reference in new issue
Block a user