The best IDE is
your favourite IDE
The best IDE is in your favourite IDE
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/10724416/pasted-from-clipboard.png)
- Scala3 Compiler Engineer
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11265888/pasted-from-clipboard.png)
- IntelliJ Plugin Developer
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11265889/pasted-from-clipboard.png)
- Scala.js Frontend Developer
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11265890/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11265894/pasted-from-clipboard.png)
- Tooling Engineer
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11265897/pasted-from-clipboard.png)
- Backend Developer
I work on tooling.
Scastie
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11265950/pasted-from-clipboard.png)
Metals
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11265972/pasted-from-clipboard.png)
IntelliJ
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266035/pasted-from-clipboard.png)
But this is not your average project
Right ?
import quoted.*
class Config(map: Map[String, Any]) extends Selectable:
def selectDynamic(name: String): Any = map(name)
transparent inline def typesafeConfig(inline pairs: (String, Any)*) =
${ typesafeConfigImpl('pairs) }
def typesafeConfigImpl(pairs: Expr[Seq[(String, Any)]])(using Quotes): Expr[Any] =
import quotes.reflect.*
val unpacked = Varargs.unapply(pairs).getOrElse(Nil).map:
case '{ ($k: String) -> ($v: t) } => k.valueOrError -> v
val typ = unpacked.foldLeft(TypeRepr.of[Config]): (acc, entry) =>
Refinement(acc, entry._1, entry._2.asTerm.tpe.widen)
val params = unpacked.map: (k, v) =>
'{ ${Expr(k)} -> $v }
typ.asType match
case '[t] => '{ Config(Map(${Varargs(params)}: _*)).asInstanceOf[t] }
MACROS
🥁
🥁
🥁
Metals
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266016/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266032/pasted-from-clipboard.png)
IntelliJ
LSP with the rescue
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266060/pasted-from-clipboard.png)
But why did IntelliJ fail ?
Compilation 101*
* The following slides are simplification
Select
"head"
Ident
"godfatherSeries"
val godfatherSeries: List[String] = ???
godfatherSeries.head
Parser
Select
"get"
Ident
Apply
0
Literal
Parser
val godfatherSeries: List[String] = ???
godfatherSeries.get(0)
"godfatherSeries"
Select
"get"
Ident
Apply
0
Literal
Typer
val godfatherSeries: List[String] = ???
godfatherSeries.get(0)
"godfatherSeries"
Select
"get"
Ident
Apply
0
Literal
Typer
val godfatherSeries: List[String] = ???
godfatherSeries.get(0)
"godfatherSeries"
?(...)
Select
"get"
Ident: godfatherSeries.type
Apply
0
Literal
Typer
val godfatherSeries: List[String] = ???
godfatherSeries.get(0)
"godfatherSeries"
?(...)
?.get
Select
"get"
Ident: godfatherSeries.type
Apply
0
Literal
Typer
val godfatherSeries: List[String] = ???
godfatherSeries.get(0)
"godfatherSeries"
?(...)
Select: godfatherSeries.get.type
"get"
Ident: godfatherSeries.type
Apply
0
Literal
Typer
val godfatherSeries: List[String] = ???
godfatherSeries.get(0)
"godfatherSeries"
?(...)
Select: godfatherSeries.get.type
"get"
Ident: godfatherSeries.type
Apply
0
Literal: 0.type
Typer
val godfatherSeries: List[String] = ???
godfatherSeries.get(0)
"godfatherSeries"
?(...)
Select: godfatherSeries.get.type
"get"
Ident: godfatherSeries.type
Apply
0
Literal: 0.type
Typer
val godfatherSeries: List[String] = ???
godfatherSeries.get(0)
"godfatherSeries"
?(Int)
Select: godfatherSeries.get.type (Int => T)
"get"
Ident: godfatherSeries.type
Apply
0
Literal: 0.type
Typer
val godfatherSeries: List[String] = ???
godfatherSeries.get(0)
"godfatherSeries"
?(Int)
Select: godfatherSeries.get.type (Int => String)
"get"
Ident: godfatherSeries.type
Apply
0
Literal: 0.type
Typer
val godfatherSeries: List[String] = ???
godfatherSeries.get(0)
"godfatherSeries"
?(Int)
Select: godfatherSeries.get.type
"get"
Ident: godfatherSeries.type
Apply: String
0
Literal: 0.type
Typer
val godfatherSeries: List[String] = ???
godfatherSeries.get(0)
"godfatherSeries"
Abstract Syntax Trees
Select
"get"
Ident
Apply
0
Literal
"godfatherSeries"
ReferenceExpression
"get"
ReferenceExpression
MethodCall
5
ArgumentList
"godfatherSeries"
PsiElement(.)
PSI - Program Structure Interface
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266226/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266227/pasted-from-clipboard.png)
PSI - Program Structure Interface
AST - Abstract Syntax Tree
This is not bad
It's just a tradeoff
Error recovery
Correctness
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266233/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266236/pasted-from-clipboard.png)
Error recovery
Correctness
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266233/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266236/pasted-from-clipboard.png)
Compiler diagnostics
Error recovery
Correctness
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266236/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266233/pasted-from-clipboard.png)
Compiler diagnostics
Error recovery
Correctness
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266236/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266233/pasted-from-clipboard.png)
BTasty format
Error recovery
Correctness
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266236/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266233/pasted-from-clipboard.png)
BTasty format
Error recovery
Correctness
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266236/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266233/pasted-from-clipboard.png)
Where am I going with this ?
Not all features require typer
But those that do, should rely on true representation
Completions
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266278/pasted-from-clipboard.png)
Hovers
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266279/pasted-from-clipboard.png)
Signature help
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266281/pasted-from-clipboard.png)
And others are more reliant on indexes
Go-to definition
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266283/pasted-from-clipboard.png)
Find usages
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266285/pasted-from-clipboard.png)
Indexes can enrich IDE
IDE can enrich indexes
I did a thing
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266060/pasted-from-clipboard.png)
But this has a very big overhead
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11272596/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266236/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266233/pasted-from-clipboard.png)
>
Resource usage
>
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11265972/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11265950/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266016/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266023/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266057/pasted-from-clipboard.png)
Completions
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266278/pasted-from-clipboard.png)
public abstract CompletableFuture<CompletionList> complete(OffsetParams params);
Hovers
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266279/pasted-from-clipboard.png)
public abstract CompletableFuture<Optional<HoverSignature>> hover(OffsetParams params);
Signature help
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266281/pasted-from-clipboard.png)
public abstract CompletableFuture<CompletionList> complete(OffsetParams params);
Go-to definition
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266283/pasted-from-clipboard.png)
public abstract CompletableFuture<SignatureHelpResult> signatureHelp(OffsetParams params);
Find usages
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266285/pasted-from-clipboard.png)
IntelliJ plugin creation 101
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266233/pasted-from-clipboard.png)
IntelliJ plugin creation 101
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266233/pasted-from-clipboard.png)
+
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11272635/pasted-from-clipboard.png)
build.sbt
import org.jetbrains.sbtidea.IntelliJPlatform.IdeaCommunity
ThisBuild / intellijPluginName := "intellij-metals"
ThisBuild / intellijPlatform := IdeaCommunity
ThisBuild / intellijBuild := "241.14494.240"
lazy val root = project
.enablePlugins(SbtIdeaPlugin)
.settings(
name := "intellij-next",
scalaVersion := 2.13.12,
intellijPlugins := Seq(
"com.intellij.java".toPlugin,
"org.intellij.scala".toPlugin
)
)
build.sbt
import org.jetbrains.sbtidea.IntelliJPlatform.IdeaCommunity
ThisBuild / intellijPluginName := "intellij-metals"
ThisBuild / intellijPlatform := IdeaCommunity
ThisBuild / intellijBuild := "241.14494.240"
lazy val root = project
.enablePlugins(SbtIdeaPlugin)
.settings(
name := "intellij-next",
scalaVersion := 2.13.12,
intellijPlugins := Seq(
"com.intellij.java".toPlugin,
"org.intellij.scala".toPlugin
),
libraryDependencies +=
"org.scala-lang" % "scala3-presentation-compiler_3" % "3.3.3",
)
<idea-plugin>
<id>intellij-metals</id>
<name>Scala LSP (Metals) for IntelliJ</name>
<version>replaced-by-build</version>
<idea-version since-build="241" until-build="241.*"/>
<depends>com.intellij.modules.platform</depends>
<depends>org.intellij.scala</depends>
<extensions defaultExtensionNs="com.intellij">
</extensions>
</idea-plugin>
plugin.xml
final class PcCompletionProvider() extends CompletionContributor {
// normal person would fetch it from database or fire a request to imdb
val paramountMovies = List("The Godfather" -> (1972))
override def fillCompletionVariants(
parameters: CompletionParameters, result: CompletionResultSet
): Unit = {
paramountMovies.foreach { case (title, year) => {
val elem = LookupElementBuilder.create(title)
val elemWithDate = elem.withTypeText(year.toString)
result.addElement(elemWithDate)
}}
result.stopHere()
super.fillCompletionVariants(parameters, result)
}
Completions
<idea-plugin>
<id>intellij-next</id>
<name>IntelliJ Next</name>
<version>4.0.0</version>
<idea-version since-build="241" until-build="241.*"/>
<depends>com.intellij.modules.platform</depends>
<depends>org.intellij.scala</depends>
<extensions defaultExtensionNs="com.intellij">
<completion.contributor
language="Scala"
implementationClass="intellij.next.PcCompletionProvider"
order="first"/>
</extensions>
</idea-plugin>
plugin.xml
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11272668/pasted-from-clipboard.png)
final class PcCompletionProvider() extends CompletionContributor {
override def fillCompletionVariants(
parameters: CompletionParameters, result: CompletionResultSet
): Unit = {
// compute real completions
result.stopHere()
super.fillCompletionVariants(parameters, result)
}
Completions
public abstract CompletableFuture[CompletionList] complete(OffsetParams params)
// This is completion api ^^^
final class PcCompletionProvider() extends CompletionContributor {
override def fillCompletionVariants(
parameters: CompletionParameters, result: CompletionResultSet
): Unit = {
// compute real completions
val presentationCompiler: PresentationCompiler = ???
val offsetParams: OffsetParams = ???
val completions = presentationCompiler.complete(offsetParams)
result.stopHere()
super.fillCompletionVariants(parameters, result)
}
Completions
public abstract CompletableFuture[CompletionList] complete(OffsetParams params)
// This is completion api ^^^
final class PcCompletionProvider() extends CompletionContributor {
override def fillCompletionVariants(
parameters: CompletionParameters, result: CompletionResultSet
): Unit = {
// compute real completions
val presentationCompiler: PresentationCompiler = ???
val offsetParams: OffsetParams = ???
val completions = presentationCompiler.complete(offsetParams)
completions.get().getItems.foreach { item =>
val elem = LookupElementBuilder.create(item.getLabel)
result.addElement(elem)
}
result.stopHere()
super.fillCompletionVariants(parameters, result)
}
Completions
public abstract CompletableFuture[CompletionList] complete(OffsetParams params)
// This is completion api ^^^
final class PcCompletionProvider() extends CompletionContributor {
override def fillCompletionVariants(
parameters: CompletionParameters, result: CompletionResultSet
): Unit = {
// compute real completions
val presentationCompiler: PresentationCompiler = ???
val file = parameters.getOriginalFile.getVirtualFile
val offsetParams: OffsetParams = CompilerOffsetParams(
file.toUri, parameters.getOriginalFile.getText, parameters.getOffset)
val completions = presentationCompiler.complete(offsetParams)
completions.get().getItems.foreach { item =>
val elem = LookupElementBuilder.create(item.getLabel)
result.addElement(elem)
}
result.stopHere()
super.fillCompletionVariants(parameters, result)
}
Completions
public abstract CompletableFuture[CompletionList] complete(OffsetParams params)
// This is completion api ^^^
final class PcCompletionProvider() extends CompletionContributor {
override def fillCompletionVariants(
parameters: CompletionParameters, result: CompletionResultSet
): Unit = {
// compute real completions
val presentationCompiler: PresentationCompiler =
Compilers.getPresentationCompiler(module)
val file = parameters.getOriginalFile.getVirtualFile
val offsetParams: OffsetParams = CompilerOffsetParams(
file.toUri, parameters.getOriginalFile.getText, parameters.getOffset)
val completions = presentationCompiler.complete(offsetParams)
completions.get().getItems.foreach { item =>
val elem = LookupElementBuilder.create(item.getLabel)
result.addElement(elem)
}
result.stopHere()
super.fillCompletionVariants(parameters, result)
}
Completions
final class Compilers(val project: Project) {
private val compilers: TrieMap[module.Module, PresentationCompiler] = new TrieMap()
def startPc(module0: module.Module): PresentationCompiler = {
val pc = new ScalaPresentationCompiler()
.newInstance(module0.getName, fullClasspath.asJava, Nil.asJava)
compilers.addOne(module0 -> pc)
pc
}
def getPresentationCompiler(module0: module.Module): PresentationCompiler =
compilers.getOrElse(module0, startPc(module0))
}
Compilers
final class Compilers(val project: Project) {
private val compilers: TrieMap[module.Module, PresentationCompiler] = new TrieMap()
def startPc(module0: module.Module): PresentationCompiler = {
val originalClasspath = OrderEnumerator.orderEntries(module0).recursively()
.withoutSdk().getClassesRoots.map(_.getPresentableUrl)
val fullClasspath = originalClasspath.toList.map(Paths.get(_))
val pc = new ScalaPresentationCompiler()
.newInstance(module0.getName, fullClasspath.asJava, Nil.asJava)
compilers.addOne(module0 -> pc)
pc
}
def getPresentationCompiler(module0: module.Module): PresentationCompiler =
compilers.getOrElse(module0, startPc(module0))
}
Compilers
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11272844/pasted-from-clipboard.png)
libraryDependencies:
/Users/jrochala/.../scala-library-2.13.12.jar
/Users/jrochala/.../scala3-library_3/3.3.3/scala3-library_3-3.3.3.jar
moduleDependencies:
/Users/jrochala/IdeaProjects/testProject/target/scala-3.3.3/classes
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11272712/pasted-from-clipboard.png)
Wait, it was that simple ?
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11272766/pasted-from-clipboard.png)
Integrating symbol index
/**
* The interface for the presentation compile
* to extract symbol documentation and perform fuzzy symbol search.
*/
public interface SymbolSearch {
Optional<SymbolDocumentation> documentation(String symbol, ParentSymbols parents);
List<Location> definition(String symbol, URI sourceUri);
List<String> definitionSourceToplevels(String symbol, URI sourceUri);
Result search(String query,
String buildTargetIdentifier,
SymbolSearchVisitor visitor);
Result searchMethods(String query,
String buildTargetIdentifier,
SymbolSearchVisitor visitor);
enum Result {
COMPLETE,
INCOMPLETE
}
}
Symbol search
/**
* Consumer of symbol search results.
*
* Search results can come from two different sources: classpath or workspace.
* Classpath results are symbols defined in library dependencies while workspace
* results are symbols that are defined by the user.
*/
public abstract class SymbolSearchVisitor {
abstract public boolean shouldVisitPackage(String pkg);
abstract public int visitClassfile(String pkg, String filename);
abstract public int visitWorkspaceSymbol(
Path path, String symbol, SymbolKind kind, Range range
);
abstract public boolean isCancelled();
}
Symbol Search Visitor
public abstract class SymbolSearchVisitor {
abstract public int visitWorkspaceSymbol(
Path path, String symbol, SymbolKind kind, Range range
);
}
public abstract class SymbolSeach {
Result search(String query,
String buildTargetIdentifier,
SymbolSearchVisitor visitor);
}
Implementation
public abstract class SymbolSearchVisitor {
abstract public int visitWorkspaceSymbol(
Path path, String symbol, SymbolKind kind, Range range
);
}
public abstract class SymbolSeach {
Result search(String query,
String buildTargetIdentifier,
SymbolSearchVisitor visitor) =
val symbols: List[PsiElement] = IntelliJCache.getSymbols(query)
symbols.foreach(visitor.visitWorkspaceSymbol(null, _, null, null))
}
Implementation
SemanticDB
object Test {
def main(args: Array[String]): Unit = {
println("hello world")
}
}
Symbols:
_empty_/Test.
_empty_/Test.main().
_empty_/Test.main().(args)
Occurrences:
[0:7..0:11) <= _empty_/Test.
[1:6..1:10) <= _empty_/Test.main().
[1:11..1:15) <= _empty_/Test.main().(args)
[1:17..1:22) => scala/Array#
[1:23..1:29) => scala/Predef.String#
[1:33..1:37) => scala/Unit#
[2:4..2:11) => scala/Predef.println(+1).
scala/Predef.String#
scala/Predef.String#
IntelliJ Symbol
scala/Predef.String#
PsiElement
scala/Predef.String#
PsiElement
Compiler Internal Symbol
private def toSemanticdbSymbol(psiClass: PsiClass): String = {
val suffix = if (psiClass.getLanguage.is(ScalaLanguage.INSTANCE)) "."
else "#"
Option(psiClass.getQualifiedName)
.map(_.replace(".", "/") + suffix)
.getOrElse("")
}
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11272635/pasted-from-clipboard.png)
* this method will not work in most cases, it is simplification for presentation purposes
final class IntelliJSymbolSearch(
cache: PsiShortNamesCache, searchScope: GlobalSearchScope) extends SymbolSearch {
override def search(
query: String, buildTargetIdentifier: String, visitor: SymbolSearchVisitor
): SymbolSearch.Result = {
if (query.length > 3) {
val allMatchingClasses = cache.getAllClassNames.filter(_.startsWith(query))
allMatchingClasses.map { classname =>
val classes = cache.getClassesByName(classname, searchScope)
classes.flatMap { psiClass =>
SemanticDbSymbolCreator.createSemanticDbSymbol(psiClass)
}.map { semanticDbSymbol =>
visitor.visitWorkspaceSymbol(
Paths.get(""), semanticDbSymbol, SymbolKind.Null, new lsp4j.Range()
)
}
}
}
SymbolSearch.Result.COMPLETE
}
}
IntelliJ Symbol Search
* this code handles only class names, it won't provide us with methods, extensions etc
final class Compilers(val project: Project) {
private val compilers: TrieMap[module.Module, PresentationCompiler] = new TrieMap()
def startPc(module0: module.Module): PresentationCompiler = {
val originalClasspath = OrderEnumerator.orderEntries(module0).recursively()
.withoutSdk().getClassesRoots.map(_.getPresentableUrl)
val fullClasspath = originalClasspath.toList.map(Paths.get(_))
val pc = new ScalaPresentationCompiler()
.newInstance(module0.getName, fullClasspath.asJava, Nil.asJava)
compilers.addOne(module0 -> pc)
pc
}
def getPresentationCompiler(module0: module.Module): PresentationCompiler =
compilers.getOrElse(module0, startPc(module0))
}
Compilers
final class Compilers(val project: Project) {
private val compilers: TrieMap[module.Module, PresentationCompiler] = new TrieMap()
def startPc(module0: module.Module): PresentationCompiler = {
val originalClasspath = OrderEnumerator.orderEntries(module0).recursively()
.withoutSdk().getClassesRoots.map(_.getPresentableUrl)
val fullClasspath = originalClasspath.toList.map(Paths.get(_))
val cache = PsiShortNamesCache.getInstance(project)
val searchScope = GlobalSearchScope
.moduleWithDependenciesAndLibrariesScope(module0, false)
val intelliJSymbolSearch = new IntelliJSymbolSearch(cache, searchScope)
val pc = new ScalaPresentationCompiler()
.withSearch(intelliJSymbolSearch)
.newInstance(module0.getName, fullClasspath.asJava, Nil.asJava)
compilers.addOne(module0 -> pc)
pc
}
def getPresentationCompiler(module0: module.Module): PresentationCompiler =
compilers.getOrElse(module0, startPc(module0))
}
Compilers
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11272746/pasted-from-clipboard.png)
![](https://s3.amazonaws.com/media-p.slid.es/uploads/2501230/images/11266023/pasted-from-clipboard.png)
Thank you
https://slides.com/rochala/the-best-ide-in-your-favourite-ide
The best IDE is your favourite IDE
By rochala
The best IDE is your favourite IDE
- 16