Resolve included files

This commit is contained in:
Patrick Förster 2018-12-22 11:37:51 +01:00
parent 9b8ece8f67
commit b3eace804a
9 changed files with 266 additions and 39 deletions

View file

@ -5,16 +5,15 @@ import java.net.URI
abstract class Document(val uri: URI) {
var version: Int = -1
private set
private var version: Int = -1
var text: String = ""
private set
protected abstract fun analyze()
val isFile: Boolean = uri.scheme == "file"
fun update(changes: List<TextDocumentContentChangeEvent>, version: Int) {
if (version > this.version) {
if (this.version > version) {
return
}
@ -33,4 +32,6 @@ abstract class Document(val uri: URI) {
this.version = version
analyze()
}
protected abstract fun analyze()
}

View file

@ -24,6 +24,11 @@ class LanguageServerImpl : LanguageServer, LanguageClientAware {
override fun initialize(params: InitializeParams): CompletableFuture<InitializeResult> {
return CompletableFuture.supplyAsync {
val root = URI.create(params.rootUri)
synchronized(workspace) {
loadWorkspace(root)
}
val capabilities = ServerCapabilities().apply {
val syncOptions = TextDocumentSyncOptions().apply {
openClose = true
@ -31,11 +36,6 @@ class LanguageServerImpl : LanguageServer, LanguageClientAware {
}
textDocumentSync = Either.forRight(syncOptions)
}
val root = URI.create(params.rootUri)
synchronized(workspace) {
loadWorkspace(root)
}
InitializeResult(capabilities)
}
}
@ -55,7 +55,7 @@ class LanguageServerImpl : LanguageServer, LanguageClientAware {
val language = getLanguageByExtension(extension) ?: return
try {
val text = Files.readAllBytes(file).toString(Charsets.UTF_8)
workspace.create(file.toUri(), text, language)
workspace.create(file.toUri(), language, text)
} catch (e: IOException) {
e.printStackTrace()
}

View file

@ -13,7 +13,7 @@ class TextDocumentServiceImpl(private val workspace: Workspace) : TextDocumentSe
params.textDocument.apply {
val language = getLanguageById(languageId) ?: return
synchronized(workspace) {
workspace.create(URI.create(uri), text, language)
workspace.create(URI.create(uri), language, text)
}
}
}

View file

@ -1,34 +1,16 @@
package texlab
import org.eclipse.lsp4j.TextDocumentContentChangeEvent
import java.io.IOException
import java.net.URI
import java.nio.file.FileSystems
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.PathMatcher
import java.nio.file.InvalidPathException
import java.nio.file.Paths
import java.util.*
class Workspace {
private val documents = mutableListOf<Document>()
private val matcher: PathMatcher = FileSystems.getDefault().getPathMatcher("glob:**.{tex,sty,cls,bib}")
private val extensions = arrayOf(".tex", ".sty", ".cls", ".bib")
fun initialize(directory: Path) {
Files.walk(directory)
.filter { Files.isRegularFile(it) }
.filter { matcher.matches(it) }
.forEach {
val extension = it.fileName.toFile().extension
val language = getLanguageByExtension(extension) ?: return@forEach
try {
val text = Files.readAllBytes(it).toString(Charsets.UTF_8)
create(it.toUri(), text, language)
} catch (e: IOException) {
// TODO: Log this error
}
}
}
fun create(uri: URI, text: String, language: Language) {
fun create(uri: URI, language: Language, text: String) {
var document = documents.firstOrNull { it.uri == uri }
if (document == null) {
document = when (language) {
@ -45,7 +27,56 @@ class Workspace {
}
fun update(uri: URI, changes: List<TextDocumentContentChangeEvent>, version: Int) {
val document = documents.firstOrNull { it.uri == uri } ?: return
document.update(changes, version)
documents.firstOrNull { it.uri == uri }
?.update(changes, version)
}
fun resolve(uri: URI, relativePath: String): Document? {
fun find(path: String): Document? {
return documents
.filter { it.isFile }
.firstOrNull { it.uri.path == path }
}
return try {
val basePath = Paths.get(uri.path).parent
val fullPath = basePath.resolve(relativePath).toString().replace('\\', '/')
var document = find(fullPath)
extensions.forEach { document = document ?: find("$fullPath$it") }
return document
} catch (e: InvalidPathException) {
null
}
}
fun relatedDocuments(uri: URI): List<Document> {
val edges = mutableSetOf<Pair<Document, Document>>()
documents.filterIsInstance<LatexDocument>()
.filter { it.isFile }
.forEach { parent ->
parent.tree.includes
.mapNotNull { resolve(parent.uri, it.path) }
.forEach { child ->
edges.add(Pair(parent, child))
edges.add(Pair(child, parent))
}
}
val results = mutableListOf<Document>()
val start = documents.firstOrNull { it.uri == uri } ?: return results
val visited = mutableSetOf<Document>()
val stack = Stack<Document>()
stack.push(start)
while (!stack.empty()) {
val current = stack.pop()
if (!visited.add(current)) {
continue
}
results.add(current)
documents.filter { edges.contains(Pair(current, it)) }
.forEach { stack.push(it) }
}
return results
}
}

View file

@ -0,0 +1,21 @@
package texlab.latex
data class LatexInclude(val command: LatexCommandSyntax, val path: String) {
companion object {
private val COMMAND_NAMES =
arrayOf("\\include", "\\input", "\\bibliography", "\\addbibresource", "\\usepackage")
fun analyze(root: LatexSyntaxNode): List<LatexInclude> {
return root.descendants()
.filterIsInstance<LatexCommandSyntax>()
.filter { COMMAND_NAMES.contains(it.name.text) }
.mapNotNull { analyze(it) }
}
private fun analyze(command: LatexCommandSyntax): LatexInclude? {
val text = command.extractText(0) ?: return null
val path = text.words.joinToString(" ") { it.text }
return LatexInclude(command, path)
}
}
}

View file

@ -11,6 +11,29 @@ sealed class LatexSyntaxNode {
val end: Position
get() = range.end
fun descendants(): List<LatexSyntaxNode> {
val results = mutableListOf<LatexSyntaxNode>()
fun visit(node: LatexSyntaxNode) {
results.add(node)
when (node) {
is LatexDocumentSyntax -> {
node.children.forEach { visit(it) }
}
is LatexGroupSyntax -> {
node.children.forEach { visit(it) }
}
is LatexCommandSyntax -> {
node.options?.also { visit(it) }
node.args.forEach { visit(it) }
}
}
}
visit(this)
return results
}
}
data class LatexDocumentSyntax(override val range: Range,
@ -24,7 +47,31 @@ data class LatexGroupSyntax(override val range: Range,
data class LatexCommandSyntax(override val range: Range,
val name: LatexToken,
val options: LatexGroupSyntax?,
val args: List<LatexGroupSyntax>) : LatexSyntaxNode()
val args: List<LatexGroupSyntax>) : LatexSyntaxNode() {
fun extractText(index: Int): LatexTextSyntax? {
return if (args.size > index && args[index].children.size == 1) {
val child = args[index].children[0]
if (child is LatexTextSyntax) {
child
} else {
null
}
} else {
null
}
}
fun extractWord(index: Int): String? {
val text = extractText(index)
return if (text == null || text.words.size != 1) {
null
} else {
text.words[0].text
}
}
}
data class LatexTextSyntax(override val range: Range,
val words: List<LatexToken>) : LatexSyntaxNode()

View file

@ -1,6 +1,8 @@
package texlab.latex
class LatexSyntaxTree(private val root: LatexDocumentSyntax) {
class LatexSyntaxTree(text: String) {
constructor(text: String) : this(LatexParser.parse(text))
private val root: LatexDocumentSyntax = LatexParser.parse(text)
val includes: List<LatexInclude> = LatexInclude.analyze(root)
}

View file

@ -0,0 +1,77 @@
package texlab
import org.eclipse.lsp4j.Position
import org.eclipse.lsp4j.Range
import org.eclipse.lsp4j.TextDocumentContentChangeEvent
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.net.URI
class WorkspaceTests {
private val document1 = URI.create("file:///foo/bar.tex")
private val document2 = URI.create("file:///foo/baz/qux.tex")
private val document3 = URI.create("file:///foo/baz.bib")
private fun Workspace.verifyRelatedDocuments(expected: List<URI>) {
val actual = relatedDocuments(expected[0]).map { it.uri }
assertEquals(expected.size, actual.size)
expected.zip(actual)
.forEach { assertEquals(it.first, it.second) }
}
@Test
fun `it should find related bibliographies`() {
val workspace = Workspace()
workspace.create(document1, Language.LATEX, "\\bibliography{baz.bib}")
workspace.create(document2, Language.LATEX, "")
workspace.create(document3, Language.BIBTEX, "")
workspace.verifyRelatedDocuments(listOf(document3, document1))
}
@Test
fun `it should append extensions when finding related documents`() {
val workspace = Workspace()
workspace.create(document1, Language.LATEX, "\\input{baz/qux}")
workspace.create(document2, Language.LATEX, "")
workspace.verifyRelatedDocuments(listOf(document1, document2))
}
@Test
fun `it should not crash when including invalid paths`() {
val workspace = Workspace()
workspace.create(document1, Language.LATEX, "\\include{<foo>?|bar|:}")
workspace.verifyRelatedDocuments(listOf(document1))
}
@Test
fun `it should not crash when including empty paths`() {
val workspace = Workspace()
workspace.create(document1, Language.LATEX, "\\include{}")
workspace.verifyRelatedDocuments(listOf(document1))
}
@Test
fun `it should ignore paths that cannot be resolved`() {
val workspace = Workspace()
workspace.create(document1, Language.LATEX, "\\addbibresource{bar}")
workspace.verifyRelatedDocuments(listOf(document1))
}
@Test
fun `it should handle include cycles`() {
val workspace = Workspace()
workspace.create(document1, Language.LATEX, "\\include{baz/qux}");
workspace.create(document2, Language.LATEX, "\\include{../bar.tex}");
workspace.verifyRelatedDocuments(listOf(document1, document2))
}
@Test
fun `it should handle incremental updates`() {
val workspace = Workspace()
workspace.create(document1, Language.LATEX, "foo\nbar")
val range = Range(Position(0, 0), Position(0, 3))
val change = TextDocumentContentChangeEvent(range, 3, "baz")
workspace.update(document1, listOf(change), 1)
assertEquals("baz\nbar", workspace.relatedDocuments(document1)[0].text)
}
}

View file

@ -0,0 +1,48 @@
package texlab.latex
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class LatexIncludeTests {
@Test
fun `it should find includes`() {
val text = "\\include{foo}\n\\input{bar/qux}"
val root = LatexParser.parse(text)
val include1 = LatexInclude(root.children[0] as LatexCommandSyntax, "foo")
val include2 = LatexInclude(root.children[1] as LatexCommandSyntax, "bar/qux")
val includes = LatexInclude.analyze(root)
assertEquals(2, includes.size)
assertEquals(include1, includes[0])
assertEquals(include2, includes[1])
}
@Test
fun `it should find bibliographies`() {
val text = "\\addbibresource{foo}\n\\bibliography{bar}"
val root = LatexParser.parse(text)
val include1 = LatexInclude(root.children[0] as LatexCommandSyntax, "foo")
val include2 = LatexInclude(root.children[1] as LatexCommandSyntax, "bar")
val includes = LatexInclude.analyze(root)
assertEquals(2, includes.size)
assertEquals(include1, includes[0])
assertEquals(include2, includes[1])
}
@Test
fun `it should handle paths with spaces`() {
val text = "\\include{foo bar.tex}"
val root = LatexParser.parse(text)
val expected = LatexInclude(root.children[0] as LatexCommandSyntax, "foo bar.tex")
val includes = LatexInclude.analyze(root)
assertEquals(1, includes.size)
assertEquals(expected, includes[0])
}
@Test
fun `it should ignore invalid commands`() {
val text = "\\include \\input{}"
val root = LatexParser.parse(text)
assertEquals(0, LatexInclude.analyze(root).size)
}
}