diff --git a/.editorconfig b/.editorconfig
index 424d5bf..4a0e9b8 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -11,7 +11,7 @@ ij_formatter_off_tag = @formatter:off
ij_formatter_on_tag = @formatter:on
ij_formatter_tags_enabled = true
ij_smart_tabs = false
-ij_visual_guides =
+ij_visual_guides = 95
ij_wrap_on_typing = false
[*.conf]
@@ -528,7 +528,7 @@ ij_scala_preserve_space_after_method_declaration_name = false
ij_scala_reformat_on_compile = false
ij_scala_replace_case_arrow_with_unicode_char = false
ij_scala_replace_for_generator_arrow_with_unicode_char = false
-ij_scala_replace_lambda_with_greek_letter = false
+ij_scala_replace_lambda_with_greek_letter = true
ij_scala_replace_map_arrow_with_unicode_char = false
ij_scala_scalafmt_config_path =
ij_scala_scalafmt_fallback_to_default_settings = false
@@ -847,6 +847,7 @@ ij_typescript_union_types_wrap = on_every_item
ij_typescript_use_chained_calls_group_indents = false
ij_typescript_use_double_quotes = true
ij_typescript_use_explicit_js_extension = auto
+ij_typescript_use_import_type = auto
ij_typescript_use_path_mapping = always
ij_typescript_use_public_modifier = false
ij_typescript_use_semicolon_after_statement = true
@@ -1026,6 +1027,7 @@ ij_javascript_union_types_wrap = on_every_item
ij_javascript_use_chained_calls_group_indents = false
ij_javascript_use_double_quotes = false
ij_javascript_use_explicit_js_extension = auto
+ij_javascript_use_import_type = auto
ij_javascript_use_path_mapping = always
ij_javascript_use_public_modifier = false
ij_javascript_use_semicolon_after_statement = false
@@ -1319,7 +1321,7 @@ ij_kotlin_wrap_elvis_expressions = 1
ij_kotlin_wrap_expression_body_functions = 1
ij_kotlin_wrap_first_method_in_call_chain = false
-[{*.har,*.jsb2,*.jsb3,*.json,.babelrc,.conf,.eslintrc,.prettierrc,.stylelintrc,bowerrc,jest.config,mcmod.info,meatball_from_mutton.json,pack.mcmeta}]
+[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.conf,.eslintrc,.prettierrc,.stylelintrc,bowerrc,jest.config,mcmod.info,meatball_from_mutton.json,pack.mcmeta}]
ij_smart_tabs = true
ij_json_array_wrapping = split_into_lines
ij_json_keep_blank_lines_in_code = 0
@@ -1401,7 +1403,7 @@ ij_markdown_min_lines_between_paragraphs = 1
ij_markdown_wrap_text_if_long = true
ij_markdown_wrap_text_inside_blockquotes = true
-[{*.pb,*.textproto}]
+[{*.pb,*.textproto,*.txtpb}]
indent_size = 2
indent_style = space
tab_width = 2
diff --git a/src/main/scala/cc/sukazyo/cono/morny/core/MornyCoeur.scala b/src/main/scala/cc/sukazyo/cono/morny/core/MornyCoeur.scala
index 53748a5..f1b0f06 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/core/MornyCoeur.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/core/MornyCoeur.scala
@@ -13,6 +13,7 @@ import cc.sukazyo.cono.morny.util.schedule.Scheduler
import cc.sukazyo.cono.morny.util.EpochDateTime.EpochMillis
import cc.sukazyo.cono.morny.util.time.WatchDog
import cc.sukazyo.cono.morny.util.GivenContext
+import cc.sukazyo.cono.morny.util.UseString.MString
import cc.sukazyo.cono.morny.util.UseThrowable.toLogString
import com.pengrad.telegrambot.TelegramBot
import com.pengrad.telegrambot.request.GetMe
@@ -119,12 +120,12 @@ class MornyCoeur (modules: List[MornyModule])(using val config: MornyConfig)(tes
val externalContext: GivenContext = GivenContext()
import cc.sukazyo.cono.morny.util.dataview.Table.format as fmtTable
logger `info`
- s"""The following Modules have been added to current Morny:
+ m"""The following Modules have been added to current Morny:
|${fmtTable(
- ("Module ID" :: "Module Name" :: "Module Version" :: Nil)::Nil :::
- modules.map(f => f.id :: f.name :: f.version :: Nil)
- ).replaceAll("\n", "\n|")}
- |""".stripMargin
+ "Module ID" :: "Module Name" :: "Module Version" :: Nil,
+ modules.map(f => f.id :: f.name :: f.version :: Nil)*
+ )}
+ |"""
///>>> BLOCK START instance configure & startup stage 1
diff --git a/src/main/scala/cc/sukazyo/cono/morny/core/bot/command/MornyInformation.scala b/src/main/scala/cc/sukazyo/cono/morny/core/bot/command/MornyInformation.scala
index 977af0d..33a54aa 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/core/bot/command/MornyInformation.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/core/bot/command/MornyInformation.scala
@@ -3,6 +3,7 @@ package cc.sukazyo.cono.morny.core.bot.command
import cc.sukazyo.cono.morny.core.{MornyCoeur, MornySystem}
import cc.sukazyo.cono.morny.core.bot.api.{ICommandAlias, ITelegramCommand}
import cc.sukazyo.cono.morny.core.bot.api.messages.{ErrorMessage, MessagingContext}
+import cc.sukazyo.cono.morny.core.Log.logger
import cc.sukazyo.cono.morny.data.MornyInformation.*
import cc.sukazyo.cono.morny.data.TelegramStickers
import cc.sukazyo.cono.morny.reporter.MornyReport
@@ -10,13 +11,14 @@ import cc.sukazyo.cono.morny.util.CommonFormat.{formatDate, formatDuration}
import cc.sukazyo.cono.morny.util.tgapi.formatting.TelegramParseEscape.escapeHtml as h
import cc.sukazyo.cono.morny.util.tgapi.InputCommand
import cc.sukazyo.cono.morny.util.tgapi.TelegramExtensions.Requests.unsafeExecute
+import cc.sukazyo.cono.morny.util.var_text
+import cc.sukazyo.cono.morny.util.var_text.VarText
import com.pengrad.telegrambot.model.Update
import com.pengrad.telegrambot.model.request.ParseMode
import com.pengrad.telegrambot.request.{SendMessage, SendPhoto, SendSticker}
import com.pengrad.telegrambot.TelegramBot
import java.lang.System
-// todo: maybe move some utils method outside
class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
private given TelegramBot = coeur.account
@@ -74,11 +76,19 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
cxt.bind_chat.id,
getAboutPic
).caption(
- s"""Morny Cono
- |来自安妮的侍从小鼠。
- |————————————————
- |$getMornyAboutLinksHTML"""
- .stripMargin
+ // language=html
+ (VarText(
+ """Morny Cono
+ |来自安妮的侍从小鼠。
+ |————————————————
+ |{about_links}""".stripMargin
+ ).render(
+ "about_links" -> getMornyAboutLinksHTML
+ ) :: Nil)
+ .map( f =>
+ logger `trace` f
+ f
+ ).head
).parseMode(ParseMode HTML).replyToMessageId(cxt.bind_message.messageId)
.unsafeExecute
@@ -127,16 +137,26 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
val versionGitHTML = if (MornySystem.GIT_COMMIT nonEmpty) s"git $getVersionGitTagHTML" else ""
SendMessage(
event.message.chat.id,
- // language=html
- s"""version:
- |- Morny ${h(MornySystem.CODENAME toUpperCase)}
- |- ${h(MornySystem.VERSION_BASE)}
$versionDeltaHTML${if (MornySystem.GIT_COMMIT nonEmpty) "\n- " + versionGitHTML else ""}
- |coeur md5_hash:
- |- ${h(MornySystem.getJarMD5)}
- |coding timestamp:
- |- ${MornySystem.CODE_TIMESTAMP}
- |- ${h(formatDate(MornySystem.CODE_TIMESTAMP, 0))} [UTC]
- |""".stripMargin
+ VarText(
+ // language=html
+ """version:
+ |- Morny {version_codename}
+ |- {version_base}
{version_delta_html}{version_git_suffix}
+ |coeur md5_hash:
+ |- {md5}
+ |coding timestamp:
+ |- {time_millis}
+ |- {time_utc} [UTC]
+ |""".stripMargin
+ ).render(
+ "version_codename" -> h(MornySystem.CODENAME.toUpperCase),
+ "version_base" -> h(MornySystem.VERSION_BASE),
+ "version_delta_html" -> versionDeltaHTML,
+ "version_git_suffix" -> (if (MornySystem.GIT_COMMIT nonEmpty) "\n- " + versionGitHTML else ""),
+ "md5" -> h(MornySystem.getJarMD5),
+ "time_millis" -> MornySystem.CODE_TIMESTAMP,
+ "time_utc" -> h(formatDate(MornySystem.CODE_TIMESTAMP, 0)),
+ )
).replyToMessageId(event.message.messageId).parseMode(ParseMode HTML)
.unsafeExecute
}
@@ -145,27 +165,48 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
def sysprop (p: String): String = System.getProperty(p)
SendMessage(
event.message.chat.id,
- /* language=html */
- s"""system:
- |- ${h(if getRuntimeHostname nonEmpty then getRuntimeHostname.get else "")}
- |- ${h(sysprop("os.name"))}
${h(sysprop("os.arch"))}
${h(sysprop("os.version"))}
- |java runtime:
- |- ${h(sysprop("java.vm.vendor"))}.${h(sysprop("java.vm.name"))}
- |- ${h(sysprop("java.vm.version"))}
- |vm memory:
- |- ${Runtime.getRuntime.totalMemory/1024/1024}
/ ${Runtime.getRuntime.maxMemory/1024/1024}
MB
- |- ${Runtime.getRuntime.availableProcessors}
cores
- |coeur version:
- |- $getVersionAllFullTagHTML
- |- ${h(MornySystem.getJarMD5)}
- |- ${h(formatDate(MornySystem.CODE_TIMESTAMP, 0))} [UTC]
- |- [${MornySystem.CODE_TIMESTAMP}
]
- |continuous:
- |- ${h(formatDuration(System.currentTimeMillis - coeur.coeurStartTimestamp))}
- |- [${System.currentTimeMillis - coeur.coeurStartTimestamp}
]
- |- ${h(formatDate(coeur.coeurStartTimestamp, 0))}
- |- [${coeur.coeurStartTimestamp}
]"""
- .stripMargin
+ VarText(
+ /* language=html */
+ """system:
+ |- {hostname}
+ |- {os.name}
{os.arch}
{os.version}
+ |java runtime:
+ |- {java.vm.vendor}.{java.vm.name}
+ |- {java.vm.version}
+ |vm memory:
+ |- {memory_used_mb}
/ {memory_available_mb}
MB
+ |- {cpu_cores}
cores
+ |coeur version:
+ |- {version_full}
+ |- {coeur_md5}
+ |- {compile_time_utc} [UTC]
+ |- [{compile_time_millis}
]
+ |continuous:
+ |- {running_duration}
+ |- [{running_duration_ms}
]
+ |- {startup_time_utc}
+ |- [{startup_time_millis}
]"""
+ .stripMargin
+ ).render(
+ "hostname" -> h(if getRuntimeHostname nonEmpty then getRuntimeHostname.get else ""),
+ "os.name" -> h(sysprop("os.name")),
+ "os.arch" -> h(sysprop("os.arch")),
+ "os.version" -> h(sysprop("os.version")),
+ "java.vm.vendor" -> h(sysprop("java.vm.vendor")),
+ "java.vm.name" -> h(sysprop("java.vm.name")),
+ "java.vm.version" -> h(sysprop("java.vm.version")),
+ "memory_used_mb" -> (Runtime.getRuntime.totalMemory/1024/1024),
+ "memory_available_mb" -> (Runtime.getRuntime.maxMemory/1024/1024),
+ "cpu_cores" -> Runtime.getRuntime.availableProcessors,
+ "version_full" -> getVersionAllFullTagHTML,
+ "coeur_md5" -> h(MornySystem.getJarMD5),
+ "compile_time_utc" -> h(formatDate(MornySystem.CODE_TIMESTAMP, 0)),
+ "compile_time_millis" -> MornySystem.CODE_TIMESTAMP,
+ "running_duration" -> h(formatDuration(System.currentTimeMillis - coeur.coeurStartTimestamp)),
+ "running_duration_ms" -> (System.currentTimeMillis - coeur.coeurStartTimestamp),
+ "startup_time_utc" -> h(formatDate(coeur.coeurStartTimestamp, 0)),
+ "startup_time_millis" -> coeur.coeurStartTimestamp
+ )
).parseMode(ParseMode HTML).replyToMessageId(event.message.messageId)
.unsafeExecute
}
@@ -174,12 +215,18 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
// if !coeur.trusted.isTrusted(update.message.from.id) then return;
SendMessage(
update.message.chat.id,
- // language=html
- s"""Coeur Task Scheduler:
- | - scheduled tasks: ${coeur.tasks.amount}
- | - scheduler status: ${coeur.tasks.state}
- | - current runner status: ${coeur.tasks.runnerState}
- |""".stripMargin
+ VarText(
+ // language=html
+ """Coeur Task Scheduler:
+ | - scheduled tasks: {coeur.tasks.amount}
+ | - scheduler status: {coeur.tasks.state}
+ | - current runner status: {coeur.tasks.runnerState}
+ |""".stripMargin
+ ).render(
+ "coeur.tasks.amount" -> coeur.tasks.amount,
+ "coeur.tasks.state" -> coeur.tasks.state,
+ "coeur.tasks.runnerState" -> coeur.tasks.runnerState
+ )
).parseMode(ParseMode.HTML).replyToMessageId(update.message.messageId)
.unsafeExecute
}
@@ -188,10 +235,14 @@ class MornyInformation (using coeur: MornyCoeur) extends ITelegramCommand {
coeur.externalContext >> { (reporter: MornyReport) =>
SendMessage(
update.message.chat.id,
- // language=html
- s"""Event Statistics :
- |in today
- |${reporter.EventStatistics.eventStatisticsHTML}""".stripMargin
+ VarText(
+ // language=html
+ """Event Statistics :
+ |in today
+ |{event_statistics}""".stripMargin
+ ).render(
+ "event_statistics" -> reporter.EventStatistics.eventStatisticsHTML
+ )
).parseMode(ParseMode.HTML).replyToMessageId(update.message.messageId)
.unsafeExecute
} || {
diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/MornyInformation.scala b/src/main/scala/cc/sukazyo/cono/morny/data/MornyInformation.scala
index 2e14d95..9718003 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/data/MornyInformation.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/data/MornyInformation.scala
@@ -1,6 +1,7 @@
package cc.sukazyo.cono.morny.data
import cc.sukazyo.cono.morny.core.{MornyAbout, MornySystem}
+import cc.sukazyo.cono.morny.util.var_text.VarText
import java.net.InetAddress
import java.rmi.UnknownHostException
@@ -39,9 +40,16 @@ object MornyInformation {
def getAboutPic: Array[Byte] = TelegramImages.IMG_ABOUT get
def getMornyAboutLinksHTML: String =
- s"""source code | backup
- |反馈 / issue tracker
- |使用说明书 / user guide & docs"""
- .stripMargin
+ VarText(
+ // language=html
+ """source code | backup
+ |反馈 / issue tracker
+ |使用说明书 / user guide & docs""".stripMargin
+ ).render(
+ "MORNY_SOURCECODE_LINK" -> MornyAbout.MORNY_SOURCECODE_LINK,
+ "MORNY_SOURCECODE_SELF_HOSTED_MIRROR_LINK" -> MornyAbout.MORNY_SOURCECODE_SELF_HOSTED_MIRROR_LINK,
+ "MORNY_ISSUE_TRACKER_LINK" -> MornyAbout.MORNY_ISSUE_TRACKER_LINK,
+ "MORNY_USER_GUIDE_LINK" -> MornyAbout.MORNY_USER_GUIDE_LINK
+ )
}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/CommonFormat.scala b/src/main/scala/cc/sukazyo/cono/morny/util/CommonFormat.scala
index 90ad990..e9f0fe1 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/util/CommonFormat.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/CommonFormat.scala
@@ -65,7 +65,7 @@ object CommonFormat {
/** human readable [[String]] that describes the millis duration.
*
- * {{{
+ * @example {{{
* scala> formatDuration(10)
* val res0: String = 10ms
*
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/ConvertByteHex.scala b/src/main/scala/cc/sukazyo/cono/morny/util/ConvertByteHex.scala
index 0d469d4..6bd2d03 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/util/ConvertByteHex.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/ConvertByteHex.scala
@@ -9,7 +9,10 @@ package cc.sukazyo.cono.morny.util
* for example, byte `0` is binary `0000 0000`, it will be converted to
* `"00"`, and the byte `-1` is binary `1111 1111` which corresponding
* `"ff"`.
- * {{{
+ *
+ * while converting byte array, the order is: the 1st element of the array
+ * will be put most forward, then the following added to the tail of hex string.
+ * @example {{{
* scala> 0.toByte.toHex
* val res6: String = 00
*
@@ -18,11 +21,7 @@ package cc.sukazyo.cono.morny.util
*
* scala> -1.toByte.toHex
* val res7: String = ff
- * }}}
- *
- * while converting byte array, the order is: the 1st element of the array
- * will be put most forward, then the following added to the tail of hex string.
- * {{{
+ *
* scala> Array[Byte](0, 1, 2, 3).toHex
* val res5: String = 00010203
* }}}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/GivenContext.scala b/src/main/scala/cc/sukazyo/cono/morny/util/GivenContext.scala
index 0d3fe54..8f29661 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/util/GivenContext.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/GivenContext.scala
@@ -23,8 +23,7 @@ object GivenContext {
/** A mutable collection that can store(provide) any typed value and read(use/consume) that value by type.
*
- * ## Simple Guide
- * {{{
+ * @example {{{
* val cxt = GivenContext()
* class BaseClass {}
* class MyImplementation extends BaseClass {}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/StringEnsure.scala b/src/main/scala/cc/sukazyo/cono/morny/util/StringEnsure.scala
index a0bfb76..c0121e3 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/util/StringEnsure.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/StringEnsure.scala
@@ -2,7 +2,6 @@ package cc.sukazyo.cono.morny.util
object StringEnsure {
-
extension (str: String) {
/** Ensure the string have a length not smaller that the given length.
@@ -36,8 +35,8 @@ object StringEnsure {
* Notice that this method have un-defined behavior when the length of the String is less than
* the character that will be kept, so change the character length that will be kept in your need.
*
- * Examples:
- * {{{
+ *
+ * @example {{{
* scala> val someUserToken = "TOKEN_UV:V927c092FV$REFV[p':V println(MString(
+ * "line1", // line 1
+ * "line2", // line 2
+ * "", // line 3
+ * null, // will be ignored
+ * "line4", // line 4
+ * ))
+ * line1
+ * line2
+ *
+ * line4
+ * }}}
+ *
+ * @since 2.0.0
+ */
+ def MString (lines: String*): String =
+ lines.filterNot(_ == null).mkString("\n")
+
+ /** A simple string interpolator implementation that fixed [[scala.collection.immutable.StringOps.stripMargin]]
+ * will remove the interpolated string's `|` when using s"" or f"" at the
+ * same time.
+ *
+ * @see [[m]]
+ */
+ implicit class MString (private val sc: StringContext) extends AnyVal:
+ /** A simple string interpolator implementation that fixed [[scala.collection.immutable.StringOps.stripMargin]]
+ * will remove the interpolated string's `|` when using s"" or f"" at
+ * the same time.
+ *
+ * It will process stripMargin for each raw texts, then do the s""
+ * interpolation. So, the interpolated string's margin character (`|`)
+ * will be kept.
+ *
+ * This should be useful when inserting a ascii table or ascii art when
+ * using string interpolator.
+ *
+ * @example {{{
+ * scala> val table =
+ * raw""" +--------+
+ * | | head |
+ * | +--------+
+ * | | body |
+ * | | next |
+ * | +--------+
+ * |""".stripMargin
+ *
+ * val table: String = " +--------+
+ * | head |
+ * +--------+
+ * | body |
+ * | next |
+ * +--------+
+ * "
+ *
+ * scala> println(table)
+ * +--------+
+ * | head |
+ * +--------+
+ * | body |
+ * | next |
+ * +--------+
+ *
+ *
+ * scala> println(
+ * s"""Here is a table:
+ * |$table
+ * |""".stripMargin)
+ * Here is a table:
+ * +--------+
+ * head |
+ * +--------+
+ * body |
+ * next |
+ * +--------+
+ *
+ *
+ *
+ * scala> println(
+ * m"""Here is a table:
+ * |$table
+ * |""")
+ * Here is a table:
+ * +--------+
+ * | head |
+ * +--------+
+ * | body |
+ * | next |
+ * +--------+
+ * }}}
+ *
+ * @since 2.0.0
+ */
+ def m (args: Any*): String = {
+ StringContext(sc.parts.map(_.stripMargin)*)
+ .s(args*)
+ }
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/dataview/Table.scala b/src/main/scala/cc/sukazyo/cono/morny/util/dataview/Table.scala
index f4a31e9..126767c 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/util/dataview/Table.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/dataview/Table.scala
@@ -2,7 +2,8 @@ package cc.sukazyo.cono.morny.util.dataview
object Table {
- def format (table: Seq[Seq[Any]]): String = {
+ def format (title: Seq[Any], data: Seq[Any]*): String = {
+ val table = data.prepended(title)
if (table.isEmpty) ""
else {
// Get column widths based on the maximum cell width in each column (+2 for a one character padding on each side)
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Task.scala b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Task.scala
index 6250d9a..7d36734 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Task.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/schedule/Task.scala
@@ -38,8 +38,7 @@ trait Task extends Ordered[Task] {
/** Returns this task's object name and the task name.
*
- * for example:
- * {{{
+ * @example {{{
* scala> val task = new Task {
* val name = "example-task"
* val scheduledTimeMillis = 0
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/var_text/VTNode.scala b/src/main/scala/cc/sukazyo/cono/morny/util/var_text/VTNode.scala
new file mode 100644
index 0000000..2d847f4
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/var_text/VTNode.scala
@@ -0,0 +1,9 @@
+package cc.sukazyo.cono.morny.util.var_text
+
+trait VTNode {
+
+ def render (vars: Map[String, String]): String
+
+ def serialize: String
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/var_text/VTNodeLiteral.scala b/src/main/scala/cc/sukazyo/cono/morny/util/var_text/VTNodeLiteral.scala
new file mode 100644
index 0000000..265d911
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/var_text/VTNodeLiteral.scala
@@ -0,0 +1,23 @@
+package cc.sukazyo.cono.morny.util.var_text
+
+case class VTNodeLiteral (
+ text: String
+) extends VTNode {
+
+ override def render (vars: Map[String, String]): String =
+ text
+
+ override def toString: String =
+ val prefix = "literal|"
+ val prefix_following = " |"
+ val pt = text.split('\n')
+ (pt.headOption.map(prefix + _).getOrElse("") ::
+ pt.drop(1).map(prefix_following + _).toList)
+ .mkString("\n")
+
+ override def serialize: String =
+ text
+ .replaceAll("/\\{", "//\\{")
+ .replaceAll("\\{", "/\\{")
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/var_text/VTNodeVar.scala b/src/main/scala/cc/sukazyo/cono/morny/util/var_text/VTNodeVar.scala
new file mode 100644
index 0000000..7b5a4a3
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/var_text/VTNodeVar.scala
@@ -0,0 +1,16 @@
+package cc.sukazyo.cono.morny.util.var_text
+
+class VTNodeVar (
+ var_id: String
+) extends VTNode {
+
+ override def render (vars: Map[String, String]): String =
+ vars.getOrElse(var_id, s"$${$var_id}")
+
+ override def toString: String =
+ s"var_def(id={$var_id})"
+
+ override def serialize: String =
+ s"{$var_id}"
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/var_text/Var.scala b/src/main/scala/cc/sukazyo/cono/morny/util/var_text/Var.scala
new file mode 100644
index 0000000..9e88f89
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/var_text/Var.scala
@@ -0,0 +1,94 @@
+package cc.sukazyo.cono.morny.util.var_text
+
+import scala.language.implicitConversions
+
+/** A Var is a key-value pair, where the key is a string, and the value is also a string.
+ *
+ * This class is used to represent a variable in the [[VarText]].
+ *
+ * You can just call the [[Var]] constructor to create a new Var, or use the implicit
+ * conversion to create a var from a tuple of ([[String]], [[String]]), or use the extension
+ * method [[Var.StringAsVarText.asVar]] to convert a [[String]] to a var.
+ *
+ * @since 2.0.0
+ *
+ * @param id The key of the variable, also known as var-id.
+ *
+ * The var-id have some limitation on the characters that can be used in it.
+ * For details, see [[Var.isLegalId]]. An illegal id will cause the constructor
+ * throws [[IllegalArgumentException]].
+ *
+ * @param text The text content of the variable.
+ */
+case class Var (
+ id: String,
+ text: String
+) {
+
+ // todo: id limitation
+ id.foreach { c =>
+ if (!Var.isLegalId(c))
+ throw new IllegalArgumentException(s"Character $c (${c.toInt}) is not allowed in a var id")
+ }
+
+ /** Create a new Var with the same text but different id.
+ * @since 2.0.0
+ */
+ def asId (id: String): Var =
+ Var(id, this.text)
+
+ /** Create a new Var with the same id but different text.
+ * @since 2.0.0
+ */
+ Var(this.id, text)
+
+ /** Unpack this Var into a ([[String]], [[String]]) tuple.
+ * @since 2.0.0
+ */
+ def unpackKV: (String, String) =
+ (id, text)
+
+}
+
+object Var {
+
+ private val ID_AVAILABLE_SYMBOLS: Set[Char] =
+ "_-.*/\\|:#@%&?;,~"
+ .toCharArray.toSet
+
+ /** Is this character is a legal var-id character.
+ *
+ * In other words, if this character is a letter, a digit, or one of the following
+ * symbols (`_` `-` `.` `*` `/` `\` `|` `:` `#` `@` `%` `&` `?` `;` `,` `~`), this
+ * character is allowed to shows in the [[Var.id]], we said this character is a
+ * legal var-id character.
+ *
+ * @since 2.0.0
+ *
+ * @return `true` if this character is legal, false otherwise.
+ */
+ def isLegalId (c: Char): Boolean =
+ c.isLetterOrDigit || ID_AVAILABLE_SYMBOLS.contains(c)
+
+ /** Convert a tuple of ([[String]], [[String]]) to a Var.
+ *
+ * The first string of the tuple will be the [[Var.id]], and the second string
+ * will be the [[Var.text]]
+ *
+ * @since 2.0.0
+ */
+ implicit def StrStrTupleAsVar (tuple: (String, Any)): Var =
+ Var(tuple._1, tuple._2.toString)
+
+ /** @see [[asVar]] */
+ implicit class StringAsVarText (text: String):
+ /** Convert this string text to a [[Var]].
+ * @since 2.0.0
+ * @param id the var-id.
+ * @return a [[Var]] that the [[Var.text text]] is this string, and the [[Var.id id]]
+ * is the given id.
+ */
+ def asVar (id: String): Var =
+ Var(id, text)
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/var_text/VarText.scala b/src/main/scala/cc/sukazyo/cono/morny/util/var_text/VarText.scala
new file mode 100644
index 0000000..c0ac344
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/var_text/VarText.scala
@@ -0,0 +1,151 @@
+package cc.sukazyo.cono.morny.util.var_text
+
+import cc.sukazyo.cono.morny.util.var_text.Var.isLegalId
+
+/** A text/string template that may contains some named replaceable variables. It's concept may
+ * be similar with scala's `StringContext` or `GString` in groovy.
+ *
+ * A [[VarText]] can contains a stream of [[VTNode]]s, each nodes can be a [[VTNodeLiteral]] or
+ * a [[VTNodeVar]].
+ *
+ * This can be rendered to a native [[String]] by calling [[render]] method with a set of [[Var]]
+ * variables. The [[VTNodeVar]] will look for the given [[Var]]s to find if there's a match, and
+ * replace itself with the value of the [[Var]], or output a placeholder if there's no match.
+ *
+ * @since 2.0.0
+ */
+trait VarText {
+
+ val nodes: List[VTNode]
+
+ /** Render this VarText with the given `(var-key -> value)` map.
+ * @since 2.0.0
+ */
+ def render (vars: Map[String, String]): String =
+ nodes.map(_.render(vars)).mkString
+
+ /** Render this VarText with the given [[Var]]s seq.
+ * @since 2.0.0
+ */
+ def render (vars: Var*): String =
+ render(Map.from(vars.toList.map(_.unpackKV)))
+
+ /** Inspect the nodes of this VarText.
+ *
+ * Each node will be rendered to a line with the node types prefix.
+ *
+ * @since 2.0.0
+ */
+ override def toString: String =
+ nodes.map(_.toString).mkString("\n")
+
+ /** Serialize this VarText to a template string.
+ *
+ * The return template string will be like the original template string that can be parsed
+ * by the [[VarText.apply(String)]] parser.
+ *
+ * @since 2.0.0
+ */
+ def serialize: String =
+ nodes.map(_.serialize).mkString
+
+}
+
+object VarText {
+
+ def apply (_nodes: VTNode*): VarText = new VarText:
+ override val nodes: List[VTNode] = _nodes.toList
+
+ private val symbol_escape = '/'
+ private val symbol_var_start = '{'
+ private val symbol_var_end = '}'
+
+ /** Parse a serialized VarText template string to a [[VarText]] object.
+ *
+ * In the current standard, the `{}` will be parsed to a [[VTNodeVar]], unless it
+ * is escaped by the escape char `/`; And the escape char can and can only escape [[VTNodeVar]]
+ * starter `{` or escape char `/` itself, any other chars following the escape char will
+ * be treated both escape char itself and the following char as a normal char; And all others
+ * will be parsed to [[VTNodeLiteral]].
+ *
+ * @since 2.0.0
+ */
+ def apply (template: String): VarText = {
+
+ val _nodes = collection.mutable.ListBuffer[VTNode]()
+
+ def newBuffer = StringBuilder()
+ var buffer: StringBuilder = newBuffer
+ def pushc (c: Char): Unit =
+ buffer += c
+ def buffer2literal(): Unit =
+ _nodes += VTNodeLiteral(buffer.toString)
+ buffer = newBuffer
+ def buffer2var(): Unit =
+ _nodes += VTNodeVar(buffer.toString drop 1)
+ buffer = newBuffer
+ sealed trait State
+ case class in_escape(it: Char) extends State
+ case object literal extends State
+ case object in_var_def extends State
+ var state: State = literal
+
+ template.foreach { i =>
+
+ def push(): Unit =
+ buffer += i
+
+ state match
+ case in_escape(e) =>
+ i match
+ case f if f == symbol_var_start =>
+ state = literal
+ push()
+ case f if f == symbol_escape =>
+ state = literal
+ push()
+ case _ =>
+ state = literal
+ pushc(e)
+ push()
+ case _: in_var_def.type =>
+ i match
+ case f if f == symbol_var_end =>
+ buffer2var()
+ state = literal
+ case _ if isLegalId(i) =>
+ push()
+ case _ =>
+ state = literal
+ push()
+ case _: literal.type =>
+ i match
+ case f if f == symbol_escape =>
+ state = in_escape(i)
+ case f if f == symbol_var_start =>
+ buffer2literal()
+ state = in_var_def
+ push()
+ case _ =>
+ push()
+
+ }
+
+ state match
+ case in_escape(e) =>
+ pushc(e)
+ case _ =>
+ buffer2literal()
+
+ new VarText:
+ override val nodes: List[VTNode] =
+ _nodes.toList
+ .filterNot {
+ case VTNodeLiteral(text) if text.isEmpty =>
+ true
+ case _ => false
+ }
+
+ }
+
+}
diff --git a/src/test/scala/cc/sukazyo/cono/morny/test/utils/var_text/VarTextTest.scala b/src/test/scala/cc/sukazyo/cono/morny/test/utils/var_text/VarTextTest.scala
new file mode 100644
index 0000000..569d75a
--- /dev/null
+++ b/src/test/scala/cc/sukazyo/cono/morny/test/utils/var_text/VarTextTest.scala
@@ -0,0 +1,21 @@
+package cc.sukazyo.cono.morny.test.utils.var_text
+
+import cc.sukazyo.cono.morny.test.MornyTests
+import cc.sukazyo.cono.morny.util.var_text.{VarText, VTNodeLiteral, VTNodeVar}
+
+class VarTextTest extends MornyTests {
+
+ "VarText template convertor works." in {
+ VarText("abcdefg {one_var}{following}it /{escaped}it and this is //double-escape-literal, with a /no-need-to-escape then {{non formatted}}xxx {missing_part")
+ .toString
+ .shouldEqual(VarText(
+ VTNodeLiteral("abcdefg "),
+ VTNodeVar("one_var"),
+ VTNodeVar("following"),
+ VTNodeLiteral("it {escaped}it and this is /double-escape-literal, with a /no-need-to-escape then "),
+ VTNodeLiteral("{{non formatted}}xxx "),
+ VTNodeLiteral("{missing_part"),
+ ).toString)
+ }
+
+}