diff --git a/src/main/kotlin/texlab/Document.kt b/src/main/kotlin/texlab/Document.kt index e29858ee..705ad6fd 100644 --- a/src/main/kotlin/texlab/Document.kt +++ b/src/main/kotlin/texlab/Document.kt @@ -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, 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() } diff --git a/src/main/kotlin/texlab/LanguageServerImpl.kt b/src/main/kotlin/texlab/LanguageServerImpl.kt index 17c979a0..76cbe403 100644 --- a/src/main/kotlin/texlab/LanguageServerImpl.kt +++ b/src/main/kotlin/texlab/LanguageServerImpl.kt @@ -24,6 +24,11 @@ class LanguageServerImpl : LanguageServer, LanguageClientAware { override fun initialize(params: InitializeParams): CompletableFuture { 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() } diff --git a/src/main/kotlin/texlab/TextDocumentServiceImpl.kt b/src/main/kotlin/texlab/TextDocumentServiceImpl.kt index b311c905..4e3de919 100644 --- a/src/main/kotlin/texlab/TextDocumentServiceImpl.kt +++ b/src/main/kotlin/texlab/TextDocumentServiceImpl.kt @@ -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) } } } diff --git a/src/main/kotlin/texlab/Workspace.kt b/src/main/kotlin/texlab/Workspace.kt index ef472485..ac61327c 100644 --- a/src/main/kotlin/texlab/Workspace.kt +++ b/src/main/kotlin/texlab/Workspace.kt @@ -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() - 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, 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 { + val edges = mutableSetOf>() + documents.filterIsInstance() + .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() + val start = documents.firstOrNull { it.uri == uri } ?: return results + val visited = mutableSetOf() + val stack = Stack() + 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 } } diff --git a/src/main/kotlin/texlab/latex/LatexInclude.kt b/src/main/kotlin/texlab/latex/LatexInclude.kt new file mode 100644 index 00000000..4cef03e5 --- /dev/null +++ b/src/main/kotlin/texlab/latex/LatexInclude.kt @@ -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 { + return root.descendants() + .filterIsInstance() + .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) + } + } +} diff --git a/src/main/kotlin/texlab/latex/LatexSyntaxNode.kt b/src/main/kotlin/texlab/latex/LatexSyntaxNode.kt index 4d43e160..7a4e73d3 100644 --- a/src/main/kotlin/texlab/latex/LatexSyntaxNode.kt +++ b/src/main/kotlin/texlab/latex/LatexSyntaxNode.kt @@ -11,6 +11,29 @@ sealed class LatexSyntaxNode { val end: Position get() = range.end + + fun descendants(): List { + val results = mutableListOf() + + 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) : LatexSyntaxNode() + val args: List) : 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) : LatexSyntaxNode() diff --git a/src/main/kotlin/texlab/latex/LatexSyntaxTree.kt b/src/main/kotlin/texlab/latex/LatexSyntaxTree.kt index 9141eeea..f9c04ed2 100644 --- a/src/main/kotlin/texlab/latex/LatexSyntaxTree.kt +++ b/src/main/kotlin/texlab/latex/LatexSyntaxTree.kt @@ -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.analyze(root) } diff --git a/src/test/kotlin/texlab/WorkspaceTests.kt b/src/test/kotlin/texlab/WorkspaceTests.kt new file mode 100644 index 00000000..b323a565 --- /dev/null +++ b/src/test/kotlin/texlab/WorkspaceTests.kt @@ -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) { + 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{?|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) + } +} diff --git a/src/test/kotlin/texlab/latex/LatexIncludeTests.kt b/src/test/kotlin/texlab/latex/LatexIncludeTests.kt new file mode 100644 index 00000000..50b64cc6 --- /dev/null +++ b/src/test/kotlin/texlab/latex/LatexIncludeTests.kt @@ -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) + } +}