Skip to content

Commit c5bf2fd

Browse files
respencer-nclclaude
andcommitted
Integrate RiddlLib for annotator, structure view, and navigation
Replace direct parser calls with RiddlLib high-level API methods: - Annotator: validateString() for full semantic validation with parseNebula() fallback for include fragments - Structure view: getTree() for AST-accurate recursive hierarchy with regex fallback for fragment files - Navigation: getOutline() for AST-accurate go-to-definition with regex fallback for fragment files Also upgrades RIDDL to 1.8.0, sbt-ossuminc to 1.3.2, and Scala to 3.7.4. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9b1f0fa commit c5bf2fd

8 files changed

Lines changed: 197 additions & 57 deletions

File tree

build.sbt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ lazy val riddlIdeaPlugin: Project =
3636
spdx = "Apache-2.0"
3737
).configure(
3838
With.basic,
39-
With.Scala3.configure(version = Some("3.4.3")),
39+
With.Scala3.configure(version = Some("3.7.4")),
4040
With.Scalatest(V.scalatest),
4141
With.coverage(0),
4242
With.BuildInfo,
@@ -50,6 +50,7 @@ lazy val riddlIdeaPlugin: Project =
5050
libraryDependencies ++= Seq(
5151
Dep.minimalJson,
5252
Dep.riddlCommands,
53+
Dep.riddlLib,
5354
Dep.junit,
5455
Dep.opentest4j
5556
),

project/Dependencies.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import sbt.*
44
object V {
55
val lang3 = "3.14.0"
66
val opentest4j = "1.3.0"
7-
val riddl = "1.0.0"
7+
val riddl = "1.8.0"
88
val scalatest = "3.2.19"
99
val scopt = "4.1.0"
1010
val slf4j = "2.0.4"
@@ -20,6 +20,7 @@ object Dep {
2020
}
2121
val kotlin = "org.jetbrains.kotlin" % "kotlin-stdlib" % "2.0.20"
2222
val riddlCommands = "com.ossuminc" %% "riddl-commands" % V.riddl
23+
val riddlLib = "com.ossuminc" %% "riddl-lib" % V.riddl
2324
val jbKotlin = "org.jetbrains.kotlin" % "kotlin-stdlib" % "2.0.20"
2425
val jbAnnotations = "org.jetbrains" % "annotations" % "24.1.0"
2526

project/plugins.sbt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// GitHub Packages resolver for sbt-ossuminc
22
resolvers += "GitHub Packages" at "https://maven.pkg.github.com/ossuminc/sbt-ossuminc"
33

4-
addSbtPlugin("com.ossuminc" % "sbt-ossuminc" % "1.2.5")
4+
addSbtPlugin("com.ossuminc" % "sbt-ossuminc" % "1.3.2")
55

66
// Note: sbt-idea-plugin is included in sbt-ossuminc 1.2.0 (version 5.0.4)
77

src/main/scala/com/ossuminc/riddl/plugins/idea/annotator/RiddlExternalAnnotator.scala

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import com.intellij.openapi.editor.Editor
1111
import com.intellij.openapi.util.TextRange
1212
import com.intellij.psi.PsiFile
1313
import com.ossuminc.riddl.language.Messages.{Message, Error, Warning, Info, SevereError, MissingWarning, StyleWarning, UsageWarning}
14-
import com.ossuminc.riddl.language.parsing.{RiddlParserInput, TopLevelParser}
14+
import com.ossuminc.riddl.RiddlLib
1515
import com.ossuminc.riddl.utils.{NullLogger, pc}
1616

1717
/** External annotator for RIDDL files.
@@ -36,19 +36,32 @@ class RiddlExternalAnnotator
3636
hasErrors: Boolean
3737
): RiddlAnnotationInfo = collectInformation(file)
3838

39-
/** Perform the actual annotation work (runs on background thread). */
39+
/** Perform the actual annotation work (runs on background thread).
40+
*
41+
* Uses RiddlLib.validateString() for full semantic validation (parse +
42+
* resolution + validation passes). If the document fails to parse as a
43+
* complete Root (e.g. it's an include fragment), falls back to
44+
* RiddlLib.parseNebula() which tolerates partial definitions.
45+
*/
4046
override def doAnnotate(info: RiddlAnnotationInfo): RiddlAnnotationResult =
4147
if info.text.isEmpty then RiddlAnnotationResult(Seq.empty)
4248
else
4349
pc.withLogger(NullLogger()) { _ =>
44-
val rpi = RiddlParserInput(info.text, info.filePath)
45-
TopLevelParser.parseNebula(rpi) match
46-
case Right(_) =>
47-
// Parse succeeded, no syntax errors
48-
RiddlAnnotationResult(Seq.empty)
49-
case Left(messages) =>
50-
// Collect error messages
51-
RiddlAnnotationResult(messages.toSeq)
50+
val vr = RiddlLib.validateString(
51+
info.text, info.filePath
52+
)(using pc)
53+
if vr.parseErrors.isEmpty then
54+
// Full validation succeeded — return all messages
55+
RiddlAnnotationResult(vr.all)
56+
else
57+
// Parse as Root failed — try fragment parsing via nebula
58+
RiddlLib.parseNebula(
59+
info.text, info.filePath
60+
)(using pc) match
61+
case Right(_) =>
62+
RiddlAnnotationResult(Seq.empty)
63+
case Left(messages) =>
64+
RiddlAnnotationResult(messages.toSeq)
5265
}
5366

5467
/** Apply the annotations to the editor (runs on UI thread). */

src/main/scala/com/ossuminc/riddl/plugins/idea/navigation/RiddlGotoDeclarationHandler.scala

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import com.intellij.codeInsight.navigation.actions.GotoDeclarationHandler
1010
import com.intellij.openapi.actionSystem.DataContext
1111
import com.intellij.openapi.editor.Editor
1212
import com.intellij.psi.{PsiElement, PsiFile}
13+
import com.ossuminc.riddl.RiddlLib
14+
import com.ossuminc.riddl.utils.{NullLogger, pc}
1315

1416
import scala.collection.mutable.ArrayBuffer
1517
import scala.util.matching.Regex
@@ -72,11 +74,27 @@ class RiddlGotoDeclarationHandler extends GotoDeclarationHandler {
7274
private def isIdentifierChar(c: Char): Boolean =
7375
c.isLetterOrDigit || c == '_'
7476

75-
/** Find all definitions of the given identifier. */
77+
/** Find all definitions of the given identifier.
78+
*
79+
* Tries RiddlLib.getOutline() first for AST-accurate results.
80+
* Falls back to regex for fragment files that can't parse as Root.
81+
*/
7682
private def findDefinitions(text: String, identifier: String): Seq[(Int, String)] = {
83+
pc.withLogger(NullLogger()) { _ =>
84+
RiddlLib.getOutline(text, "navigation")(using pc) match
85+
case Right(entries) =>
86+
entries
87+
.filter(_.id == identifier)
88+
.map(e => (e.offset, e.kind.toLowerCase))
89+
case Left(_) =>
90+
findDefinitionsRegex(text, identifier)
91+
}
92+
}
93+
94+
/** Regex-based fallback for finding definitions in fragment files. */
95+
private def findDefinitionsRegex(text: String, identifier: String): Seq[(Int, String)] = {
7796
val results = ArrayBuffer[(Int, String)]()
7897

79-
// Patterns for different definition types
8098
val patterns: Seq[(String, Regex)] = Seq(
8199
("domain", s"""(?m)^\\s*(domain)\\s+($identifier)\\s""".r),
82100
("context", s"""(?m)^\\s*(context)\\s+($identifier)\\s""".r),

src/main/scala/com/ossuminc/riddl/plugins/idea/structure/RiddlStructureElements.scala

Lines changed: 66 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import com.intellij.ide.structureView.StructureViewTreeElement
1010
import com.intellij.ide.util.treeView.smartTree.{SortableTreeElement, TreeElement}
1111
import com.intellij.navigation.{ItemPresentation, NavigationItem}
1212
import com.intellij.psi.PsiFile
13-
import com.ossuminc.riddl.language.parsing.{RiddlParserInput, TopLevelParser}
14-
import com.ossuminc.riddl.language.At
15-
import com.ossuminc.riddl.utils.{NullLogger, PlatformContext, pc}
13+
import com.ossuminc.riddl.RiddlLib
14+
import com.ossuminc.riddl.passes.TreeNode
15+
import com.ossuminc.riddl.utils.{NullLogger, pc}
1616

1717
import javax.swing.Icon
1818
import scala.collection.mutable.ArrayBuffer
@@ -94,12 +94,68 @@ class RiddlDefinitionElement(definition: RiddlDefinition, psiFile: PsiFile)
9494
override def canNavigateToSource: Boolean = true
9595
}
9696

97-
/** Parser for extracting RIDDL definitions from source text. */
97+
/** Parser for extracting RIDDL definitions from source text.
98+
*
99+
* Tries RiddlLib.getTree() first for AST-accurate structure with full
100+
* hierarchy. Falls back to regex-based parsing for fragment files that
101+
* can't parse as a complete Root document.
102+
*/
98103
object RiddlStructureParser {
99104

100-
/** Regex patterns for extracting definitions.
101-
* Each pattern captures: optional description, keyword, name
105+
/** Parse definitions from RIDDL source text.
106+
*
107+
* Uses RiddlLib.getTree() for accurate, recursive structure when the
108+
* text is a valid Root document. Falls back to regex for fragments.
102109
*/
110+
def parseDefinitions(text: String): Seq[RiddlDefinition] = {
111+
pc.withLogger(NullLogger()) { _ =>
112+
RiddlLib.getTree(text, "structure")(using pc) match
113+
case Right(treeNodes) =>
114+
val mapped = treeNodes.map(node => mapTreeNode(text, node))
115+
// Unwrap Root nodes — structure view starts at domain level
116+
mapped.flatMap { defn =>
117+
if defn.kind == "root" then defn.children
118+
else Seq(defn)
119+
}
120+
case Left(_) =>
121+
parseDefinitionsRegex(text)
122+
}
123+
}
124+
125+
/** Map a RiddlLib TreeNode to a RiddlDefinition. */
126+
private def mapTreeNode(text: String, node: TreeNode): RiddlDefinition = {
127+
val endOff = findClosingBrace(text, node.offset)
128+
RiddlDefinition(
129+
kind = node.kind.toLowerCase,
130+
name = node.id,
131+
offset = node.offset,
132+
endOffset = endOff,
133+
children = node.children.map(child => mapTreeNode(text, child))
134+
)
135+
}
136+
137+
/** Find the matching closing brace starting from the given position. */
138+
private def findClosingBrace(text: String, startPos: Int): Int = {
139+
var level = 0
140+
var foundOpenBrace = false
141+
var i = startPos
142+
while i < text.length do {
143+
val c = text.charAt(i)
144+
if c == '{' then {
145+
level += 1
146+
foundOpenBrace = true
147+
} else if c == '}' then {
148+
level -= 1
149+
if foundOpenBrace && level == 0 then return i + 1
150+
}
151+
i += 1
152+
}
153+
text.length
154+
}
155+
156+
// --- Regex fallback for fragment files ---
157+
158+
/** Regex patterns for extracting definitions. */
103159
private val DEFINITION_PATTERNS: Seq[(String, scala.util.matching.Regex)] = Seq(
104160
("domain", """(?m)^\s*(domain)\s+(\w+)""".r),
105161
("context", """(?m)^\s*(context)\s+(\w+)""".r),
@@ -126,15 +182,10 @@ object RiddlStructureParser {
126182
("record", """(?m)^\s*(record)\s+(\w+)""".r)
127183
)
128184

129-
/** Parse definitions from RIDDL source text.
130-
*
131-
* This uses a simplified regex-based approach for structure view.
132-
* It extracts top-level and nested definitions without full parsing.
133-
*/
134-
def parseDefinitions(text: String): Seq[RiddlDefinition] = {
185+
/** Regex-based fallback for parsing definitions from fragment files. */
186+
private[structure] def parseDefinitionsRegex(text: String): Seq[RiddlDefinition] = {
135187
val allMatches = ArrayBuffer[(String, String, Int, Int)]()
136188

137-
// Find all definition matches
138189
DEFINITION_PATTERNS.foreach { case (kind, pattern) =>
139190
pattern.findAllMatchIn(text).foreach { m =>
140191
val name = m.group(2)
@@ -144,10 +195,7 @@ object RiddlStructureParser {
144195
}
145196
}
146197

147-
// Sort by position
148198
val sorted = allMatches.sortBy(_._3).toSeq
149-
150-
// Build hierarchy based on brace nesting
151199
buildHierarchy(text, sorted)
152200
}
153201

@@ -158,7 +206,6 @@ object RiddlStructureParser {
158206
): Seq[RiddlDefinition] = {
159207
if definitions.isEmpty then return Seq.empty
160208

161-
// Calculate nesting level at each definition's position
162209
def nestingLevel(position: Int): Int = {
163210
var level = 0
164211
var i = 0
@@ -170,39 +217,18 @@ object RiddlStructureParser {
170217
level
171218
}
172219

173-
// Find the end of a definition (matching closing brace)
174-
def findDefinitionEnd(startPos: Int): Int = {
175-
var level = 0
176-
var foundOpenBrace = false
177-
var i = startPos
178-
while i < text.length do {
179-
val c = text.charAt(i)
180-
if c == '{' then {
181-
level += 1
182-
foundOpenBrace = true
183-
} else if c == '}' then {
184-
level -= 1
185-
if foundOpenBrace && level == 0 then return i + 1
186-
}
187-
i += 1
188-
}
189-
text.length
190-
}
191-
192-
// Build tree structure
193220
val withLevels = definitions.map { case (kind, name, start, end) =>
194-
(kind, name, start, findDefinitionEnd(start), nestingLevel(start))
221+
(kind, name, start, findClosingBrace(text, start), nestingLevel(start))
195222
}
196223

197-
// Get top-level definitions (level 0)
198224
val topLevel = withLevels.filter(_._5 == 0)
199225

200226
topLevel.map { case (kind, name, start, end, _) =>
201227
val children = withLevels
202228
.filter { case (_, _, childStart, childEnd, level) =>
203229
level > 0 && childStart > start && childEnd <= end
204230
}
205-
.filter(_._5 == 1) // Only direct children (level 1)
231+
.filter(_._5 == 1)
206232
.map { case (ck, cn, cs, ce, _) =>
207233
RiddlDefinition(ck, cn, cs, ce)
208234
}

src/test/scala/com/ossuminc/riddl/plugins/idea/annotator/RiddlExternalAnnotatorSpec.scala

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
package com.ossuminc.riddl.plugins.idea.annotator
88

9+
import com.ossuminc.riddl.language.Messages.{Error, SevereError}
910
import org.scalatest.matchers.must.Matchers
1011
import org.scalatest.wordspec.AnyWordSpec
1112

@@ -29,14 +30,19 @@ class RiddlExternalAnnotatorSpec extends AnyWordSpec with Matchers {
2930
info.filePath mustBe filePath
3031
}
3132

32-
"return empty result for valid RIDDL" in {
33+
"return no errors for valid RIDDL" in {
3334
val annotator = new RiddlExternalAnnotator()
3435
// RIDDL requires at least a ??? placeholder or actual content
3536
val info = RiddlAnnotationInfo("domain Test is { ??? }", "/test/example.riddl")
3637

3738
val result = annotator.doAnnotate(info)
3839

39-
result.messages mustBe empty
40+
// Full validation may produce MissingWarning/StyleWarning for
41+
// minimal models, but should have no Error-level messages
42+
val errors = result.messages.filter(m =>
43+
m.kind == Error || m.kind == SevereError
44+
)
45+
errors mustBe empty
4046
}
4147

4248
"return empty result for empty text" in {
@@ -79,6 +85,43 @@ class RiddlExternalAnnotatorSpec extends AnyWordSpec with Matchers {
7985

8086
val result = annotator.doAnnotate(info)
8187

88+
// Full validation may produce warnings for minimal models
89+
// (missing descriptions, empty metadata, unused types) but
90+
// should have no Error-level messages
91+
val errors = result.messages.filter(m =>
92+
m.kind == Error || m.kind == SevereError
93+
)
94+
errors mustBe empty
95+
}
96+
97+
"detect semantic errors via full validation" in {
98+
val annotator = new RiddlExternalAnnotator()
99+
val text =
100+
"""domain Test is {
101+
| context C is {
102+
| type Ref is reference to UndefinedEntity
103+
| }
104+
|}""".stripMargin
105+
val info = RiddlAnnotationInfo(text, "/test/example.riddl")
106+
107+
val result = annotator.doAnnotate(info)
108+
109+
// Full validation should find unresolvable reference
110+
result.messages.nonEmpty mustBe true
111+
}
112+
113+
"handle RIDDL fragments via nebula fallback" in {
114+
val annotator = new RiddlExternalAnnotator()
115+
// This is a fragment (not a complete Root document)
116+
val text =
117+
"""context TestContext is {
118+
| type UserId is String
119+
|}""".stripMargin
120+
val info = RiddlAnnotationInfo(text, "/test/fragment.riddl")
121+
122+
val result = annotator.doAnnotate(info)
123+
124+
// Fragment should parse OK via nebula fallback
82125
result.messages mustBe empty
83126
}
84127
}

0 commit comments

Comments
 (0)