commit b6916f108265fcc8662d7a1dbc9c4179e621d5ea Author: Regen Date: Tue Nov 12 01:57:28 2019 +0900 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..331aea5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,133 @@ +### https://raw.github.com/github/gitignore/cb0c6ef7ac68f2300409ee85501d9ad432cb4c7e/Python.gitignore + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + + diff --git a/.style.yapf b/.style.yapf new file mode 100644 index 0000000..557fa7b --- /dev/null +++ b/.style.yapf @@ -0,0 +1,2 @@ +[style] +based_on_style = pep8 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7f2340 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# cmake-language-server + +CMake LSP Implementation. + +Alpha Stage, work in progress. + +## Features +- [x] Builtin command completion +- [x] Documentation for commands and variables on hover +- [x] Formatting + +## Installation + +### Clients + +- Neovim ([neoclide/coc.nvim][coc.nvim]) + +#### Neovim + +```jsonc + "languageserver": { + "cmake": { + "command": "cmake-language-server", + "filetypes": ["cmake"], + "rootPatterns": [ + "build/" + ], + "initializationOptions": { + "buildDirectory": "build" + } + } + } +``` + + +[coc.nvim]: https://github.com/neoclide/coc.nvim diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..79be4e7 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,6 @@ +[mypy] +ignore_missing_imports = True +allow_redefinition = True + +[mypy-re.Scanner] +ignore_errors = True diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..afc521b --- /dev/null +++ b/poetry.lock @@ -0,0 +1,346 @@ +[[package]] +category = "dev" +description = "Atomic file writes." +name = "atomicwrites" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.3.0" + +[[package]] +category = "dev" +description = "Classes Without Boilerplate" +name = "attrs" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "19.3.0" + +[[package]] +category = "dev" +description = "Cross-platform colored terminal text." +marker = "sys_platform == \"win32\"" +name = "colorama" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.4.1" + +[[package]] +category = "dev" +description = "Discover and load entry points from installed packages." +name = "entrypoints" +optional = false +python-versions = ">=2.7" +version = "0.3" + +[[package]] +category = "dev" +description = "A platform independent file lock." +name = "filelock" +optional = false +python-versions = "*" +version = "3.0.12" + +[[package]] +category = "dev" +description = "the modular source code checker: pep8, pyflakes and co" +name = "flake8" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.7.8" + +[package.dependencies] +entrypoints = ">=0.3.0,<0.4.0" +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.5.0,<2.6.0" +pyflakes = ">=2.1.0,<2.2.0" + +[[package]] +category = "dev" +description = "Read metadata from Python packages" +marker = "python_version < \"3.8\"" +name = "importlib-metadata" +optional = false +python-versions = ">=2.7,!=3.0,!=3.1,!=3.2,!=3.3" +version = "0.23" + +[package.dependencies] +zipp = ">=0.5" + +[[package]] +category = "dev" +description = "A Python utility / library to sort Python imports." +name = "isort" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "4.3.21" + +[[package]] +category = "dev" +description = "McCabe checker, plugin for flake8" +name = "mccabe" +optional = false +python-versions = "*" +version = "0.6.1" + +[[package]] +category = "dev" +description = "More routines for operating on iterables, beyond itertools" +name = "more-itertools" +optional = false +python-versions = ">=3.4" +version = "7.2.0" + +[[package]] +category = "dev" +description = "Optional static typing for Python" +name = "mypy" +optional = false +python-versions = ">=3.5" +version = "0.740" + +[package.dependencies] +mypy-extensions = ">=0.4.0,<0.5.0" +typed-ast = ">=1.4.0,<1.5.0" +typing-extensions = ">=3.7.4" + +[[package]] +category = "dev" +description = "Experimental type system extensions for programs checked with the mypy typechecker." +name = "mypy-extensions" +optional = false +python-versions = "*" +version = "0.4.3" + +[[package]] +category = "dev" +description = "Core utilities for Python packages" +name = "packaging" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "19.2" + +[package.dependencies] +pyparsing = ">=2.0.2" +six = "*" + +[[package]] +category = "dev" +description = "plugin and hook calling mechanisms for python" +name = "pluggy" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.13.0" + +[package.dependencies] +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" + +[[package]] +category = "dev" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +name = "py" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.8.0" + +[[package]] +category = "dev" +description = "Python style guide checker" +name = "pycodestyle" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.5.0" + +[[package]] +category = "dev" +description = "passive checker of Python programs" +name = "pyflakes" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.1.1" + +[[package]] +category = "main" +description = "a pythonic generic language server (pronounced like \"pie glass\")." +name = "pygls" +optional = false +python-versions = "*" +version = "0.8.1" + +[[package]] +category = "main" +description = "Python parsing module" +name = "pyparsing" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "2.4.2" + +[[package]] +category = "dev" +description = "pytest: simple powerful testing with Python" +name = "pytest" +optional = false +python-versions = ">=3.5" +version = "5.2.1" + +[package.dependencies] +atomicwrites = ">=1.0" +attrs = ">=17.4.0" +colorama = "*" +more-itertools = ">=4.0.0" +packaging = "*" +pluggy = ">=0.12,<1.0" +py = ">=1.5.0" +wcwidth = "*" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" + +[[package]] +category = "dev" +description = "pytest plugin for test data directories and files" +name = "pytest-datadir" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.3.1" + +[package.dependencies] +pytest = ">=2.7.0" + +[[package]] +category = "dev" +description = "Python 2 and 3 compatibility utilities" +name = "six" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*" +version = "1.12.0" + +[[package]] +category = "dev" +description = "Python Library for Tom's Obvious, Minimal Language" +name = "toml" +optional = false +python-versions = "*" +version = "0.10.0" + +[[package]] +category = "dev" +description = "tox is a generic virtualenv management and test command line tool" +name = "tox" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.14.0" + +[package.dependencies] +filelock = ">=3.0.0,<4" +packaging = ">=14" +pluggy = ">=0.12.0,<1" +py = ">=1.4.17,<2" +six = ">=1.0.0,<2" +toml = ">=0.9.4" +virtualenv = ">=14.0.0" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12,<1" + +[[package]] +category = "dev" +description = "a fork of Python 2 and 3 ast modules with type comment support" +name = "typed-ast" +optional = false +python-versions = "*" +version = "1.4.0" + +[[package]] +category = "dev" +description = "Type Hints for Python" +name = "typing" +optional = false +python-versions = "*" +version = "3.7.4.1" + +[[package]] +category = "dev" +description = "Backported and Experimental Type Hints for Python 3.5+" +name = "typing-extensions" +optional = false +python-versions = "*" +version = "3.7.4" + +[package.dependencies] +typing = ">=3.7.4" + +[[package]] +category = "dev" +description = "Virtual Python Environment builder" +name = "virtualenv" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +version = "16.7.7" + +[[package]] +category = "dev" +description = "Measures number of Terminal column cells of wide-character codes" +name = "wcwidth" +optional = false +python-versions = "*" +version = "0.1.7" + +[[package]] +category = "dev" +description = "A formatter for Python code." +name = "yapf" +optional = false +python-versions = "*" +version = "0.28.0" + +[[package]] +category = "dev" +description = "Backport of pathlib-compatible object wrapper for zip files" +marker = "python_version < \"3.8\"" +name = "zipp" +optional = false +python-versions = ">=2.7" +version = "0.6.0" + +[package.dependencies] +more-itertools = "*" + +[metadata] +content-hash = "2fa2f64a1c51f6312594f611baa29f434f3a8ede16543988ac0d76a6de587c7f" +python-versions = "^3.6" + +[metadata.hashes] +atomicwrites = ["03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", "75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"] +attrs = ["08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"] +colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] +entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"] +filelock = ["18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", "929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"] +flake8 = ["19241c1cbc971b9962473e4438a2ca19749a7dd002dd1a946eaba171b4114548", "8e9dfa3cecb2400b3738a42c54c3043e821682b9c840b0448c0503f781130696"] +importlib-metadata = ["aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", "d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"] +isort = ["54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", "6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"] +mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] +more-itertools = ["409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", "92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"] +mypy = ["1521c186a3d200c399bd5573c828ea2db1362af7209b2adb1bb8532cea2fb36f", "31a046ab040a84a0fc38bc93694876398e62bc9f35eca8ccbf6418b7297f4c00", "3b1a411909c84b2ae9b8283b58b48541654b918e8513c20a400bb946aa9111ae", "48c8bc99380575deb39f5d3400ebb6a8a1cb5cc669bbba4d3bb30f904e0a0e7d", "540c9caa57a22d0d5d3c69047cc9dd0094d49782603eb03069821b41f9e970e9", "672e418425d957e276c291930a3921b4a6413204f53fe7c37cad7bc57b9a3391", "6ed3b9b3fdc7193ea7aca6f3c20549b377a56f28769783a8f27191903a54170f", "9371290aa2cad5ad133e4cdc43892778efd13293406f7340b9ffe99d5ec7c1d9", "ace6ac1d0f87d4072f05b5468a084a45b4eda970e4d26704f201e06d47ab2990", "b428f883d2b3fe1d052c630642cc6afddd07d5cd7873da948644508be3b9d4a7", "d5bf0e6ec8ba346a2cf35cb55bf4adfddbc6b6576fcc9e10863daa523e418dbb", "d7574e283f83c08501607586b3167728c58e8442947e027d2d4c7dcd6d82f453", "dc889c84241a857c263a2b1cd1121507db7d5b5f5e87e77147097230f374d10b", "f4748697b349f373002656bf32fede706a0e713d67bfdcf04edf39b1f61d46eb"] +mypy-extensions = ["090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", "2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"] +packaging = ["28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", "d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"] +pluggy = ["0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6", "fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34"] +py = ["64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", "dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"] +pycodestyle = ["95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"] +pyflakes = ["17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", "d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"] +pygls = ["3ee878a828b7bc0873a2ea44208d6846a91aa7dbbbdc052e7fe8cc689f6644fa", "780fd0c5ae95ad02ecaf70b071e43ff8ced8384b7d6bed19311a7b431d26fb88"] +pyparsing = ["6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", "d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"] +pytest = ["7e4800063ccfc306a53c461442526c5571e1462f61583506ce97e4da6a1d88c8", "ca563435f4941d0cb34767301c27bc65c510cb82e90b9ecf9cb52dc2c63caaa0"] +pytest-datadir = ["1847ed0efe0bc54cac40ab3fba6d651c2f03d18dd01f2a582979604d32e7621e", "d3af1e738df87515ee509d6135780f25a15959766d9c2b2dbe02bf4fb979cb18"] +six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] +toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] +tox = ["0bc216b6a2e6afe764476b4a07edf2c1dab99ed82bb146a1130b2e828f5bff5e", "c4f6b319c20ba4913dbfe71ebfd14ff95d1853c4231493608182f66e566ecfe1"] +typed-ast = ["1170afa46a3799e18b4c977777ce137bb53c7485379d9706af8a59f2ea1aa161", "18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", "262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", "2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", "354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", "48e5b1e71f25cfdef98b013263a88d7145879fbb2d5185f2a0c79fa7ebbeae47", "4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", "630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", "66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", "71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", "7954560051331d003b4e2b3eb822d9dd2e376fa4f6d98fee32f452f52dd6ebb2", "838997f4310012cf2e1ad3803bce2f3402e9ffb71ded61b5ee22617b3a7f6b6e", "95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", "bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", "cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", "d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", "d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", "d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", "fdc1c9bbf79510b76408840e009ed65958feba92a88833cdceecff93ae8fff66", "ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"] +typing = ["91dfe6f3f706ee8cc32d38edbbf304e9b7583fb37108fef38229617f8b3eba23", "c8cabb5ab8945cd2f54917be357d134db9cc1eb039e59d1606dc1e60cb1d9d36", "f38d83c5a7a7086543a0f649564d661859c5146a85775ab90c0d2f93ffaa9714"] +typing-extensions = ["2ed632b30bb54fc3941c382decfd0ee4148f5c591651c9272473fea2c6397d95", "b1edbbf0652660e32ae780ac9433f4231e7339c7f9a8057d0f042fcbcea49b87", "d8179012ec2c620d3791ca6fe2bf7979d979acdbef1fca0bc56b37411db682ed"] +virtualenv = ["11cb4608930d5fd3afb545ecf8db83fa50e1f96fc4fca80c94b07d2c83146589", "d257bb3773e48cac60e475a19b608996c73f4d333b3ba2e4e57d5ac6134e0136"] +wcwidth = ["3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", "f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"] +yapf = ["02ace10a00fa2e36c7ebd1df2ead91dbfbd7989686dc4ccbdc549e95d19f5780", "6f94b6a176a7c114cfa6bad86d40f259bbe0f10cf2fa7f2f4b3596fc5802a41b"] +zipp = ["3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", "f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ad516ef --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[tool.poetry] +name = "cmake-language-server" +version = "0.1.0" +description = "CMake LSP Implementation" +authors = ["regen"] +license = "MIT" +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Topic :: Software Development", + "Topic :: Text Editors :: Integrated Development Environments (IDE)", + "Topic :: Utilities" +] +keywords = ["cmake", "completion", "vim"] + +[tool.poetry.dependencies] +python = "^3.6" +pygls = "^0.8.1" +pyparsing = "^2.4" + +[tool.poetry.dev-dependencies] +flake8 = "^3.7" +mypy = "^0.740.0" +pytest = "^5.2" +yapf = "^0.28.0" +pytest-datadir = "^1.3" +tox = "^3.14" +isort = "^4.3" + +[tool.poetry.scripts] +cmake-format = "cmake_language_server.formatter:main" +cmake-language-server = "cmake_language_server.server:main" + +[build-system] +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" diff --git a/src/cmake_language_server/__init__.py b/src/cmake_language_server/__init__.py new file mode 100644 index 0000000..b794fd4 --- /dev/null +++ b/src/cmake_language_server/__init__.py @@ -0,0 +1 @@ +__version__ = '0.1.0' diff --git a/src/cmake_language_server/api.py b/src/cmake_language_server/api.py new file mode 100644 index 0000000..5ee8876 --- /dev/null +++ b/src/cmake_language_server/api.py @@ -0,0 +1,255 @@ +import json +import logging +import re +import subprocess +import tempfile +import uuid +from pathlib import Path +from typing import Dict, List, Optional, Pattern + +logger = logging.getLogger(__name__) + + +class API(object): + _cmake: str + _build: Path + _uuid: uuid.UUID + _builtin_commands: Dict[str, str] + _builtin_variables: Dict[str, str] + _builtin_variable_template: Dict[Pattern, str] + _targets: List[str] + _cached_variables: Dict[str, str] + _generated_list_parsed: bool + + def __init__(self, cmake: str, build: Path): + self._cmake = cmake + self._build = Path(build) + self._uuid = uuid.uuid4() + + self._builtin_commands = {} + self._builtin_variables = {} + self._builtin_variable_template = {} + self._targets = [] + self._cached_variables = {} + self._generated_list_parsed = False + + def query(self) -> bool: + if not self.cmake_cache.exists(): + return False + + self.query_json.parent.mkdir(parents=True, exist_ok=True) + with self.query_json.open('w') as fp: + fp.write('''\ +{ + "requests": [ + {"kind": "codemodel", "version": 2}, + {"kind": "cache", "version": 2}, + {"kind": "cmakeFiles", "version": 1} + ] +}''') + + proc = subprocess.run([self._cmake, self._build], + universal_newlines=True, + capture_output=True) + self.query_json.unlink() + self.query_json.parent.rmdir() + if proc.returncode != 0: + logging.error( + f'cmake exited with {proc.returncode}: {proc.stderr}') + return False + + return True + + def read_reply(self) -> bool: + reply = self._build / '.cmake' / 'api' / 'v1' / 'reply' + indices = sorted(reply.glob('index-*.json')) + if not indices: + logger.error('no reply') + return False + with indices[-1].open() as fp: + index = json.load(fp) + try: + responses = index['reply'][f'client-{self._uuid}']['query.json'][ + 'responses'] + except KeyError: + logger.error('no rensponse') + return False + for response in responses: + if response['kind'] == 'codemodel': + self._read_codemodel(reply / response['jsonFile']) + elif response['kind'] == 'cache': + self._read_cache(reply / response['jsonFile']) + elif response['kind'] == 'cmakeFiles': + self._read_cmake_files(reply / response['jsonFile']) + + return True + + def _read_codemodel(self, codemodelpath: Path): + with (codemodelpath).open() as fp: + codemodel = json.load(fp) + config = codemodel['configurations'][0] + self._targets[:] = [x['name'] for x in config['targets']] + + def _read_cache(self, cachepath: Path): + with cachepath.open() as fp: + cache = json.load(fp) + self._cached_variables.clear() + for entry in cache['entries']: + name = entry['name'] + value = self._truncate_variable(entry['value']) + properties = {x['name']: x['value'] for x in entry['properties']} + helpstring = properties.get('HELPSTRING', '') + doc = [] + if helpstring: + doc.append(helpstring) + if value: + doc.append(f'`{value}`') + self._cached_variables[name] = '\n\n'.join(doc) + + def _read_cmake_files(self, jsonpath: Path): + '''inspect generated list files''' + + if not self._builtin_variables or self._generated_list_parsed: + return + + with jsonpath.open() as fp: + cmake_files = json.load(fp) + + # inspect generated list files + with tempfile.TemporaryDirectory() as tmpdirname: + tmplist = Path(tmpdirname) / 'dump.cmake' + with tmplist.open('w') as fp: + for listfile in cmake_files['inputs']: + if not listfile.get('isGenerated', False): + continue + path = listfile['path'] + fp.write(f'include({path})\n') + fp.write(''' +get_cmake_property(variables VARIABLES) +foreach (variable ${variables}) + message("${variable}=${${variable}}") +endforeach() +''') + p = subprocess.run([self._cmake, '-P', tmplist], + cwd=cmake_files['paths']['source'], + universal_newlines=True, + capture_output=True) + if p.returncode != 0: + return + + for line in p.stderr.split('\n'): + line = line.strip() + if not line: + continue + k, v = line.split('=', 1) + if k.startswith('CMAKE_ARG'): + continue + v = self._truncate_variable(v) + if k in self._builtin_variables: + self._builtin_variables[k] += f'\n\n`{v}`' + else: + for pattern, doc in self._builtin_variable_template.items( + ): + if pattern.fullmatch(k): + self._builtin_variables[k] = f'{doc}\n\n`{v}`' + break + else: + # ignore variable with no document + pass + + self._generated_list_parsed = True + + @property + def query_json(self) -> Path: + return (self._build / '.cmake' / 'api' / 'v1' / 'query' / + f'client-{self._uuid}' / 'query.json') + + @property + def cmake_cache(self) -> Path: + return self._build / 'CMakeCache.txt' + + def parse_doc(self) -> None: + self._parse_commands() + self._parse_variables() + + def _parse_commands(self) -> None: + p = subprocess.run([self._cmake, '--help-commands'], + stdout=subprocess.PIPE, + universal_newlines=True) + + if p.returncode != 0: + return + + matches = re.finditer( + r''' +(?P.+)\n +-+\n\n +[\s\S]*? +(?P\ (?P=command)\s*\([^)]*\)) +''', p.stdout, re.VERBOSE) + self._builtin_commands.clear() + for match in matches: + command = match.group('command') + signature = match.group('signature') + signature = re.sub(r'^ ', r'', signature, flags=re.MULTILINE) + self._builtin_commands[ + command] = '```cmake\n' + signature + '\n```' + + def _parse_variables(self) -> None: + p = subprocess.run([self._cmake, '--help-variables'], + stdout=subprocess.PIPE, + universal_newlines=True) + + if p.returncode != 0: + return + + matches = re.finditer( + r''' +(?P.+)\n +-+\n\n +(?P[\s\S]+?)(?:\n\n|$) +''', p.stdout, re.VERBOSE) + self._builtin_variables.clear() + for match in matches: + variable = match.group('variable') + doc = match.group('doc') + doc = re.sub(r':.+:`(.+)`', r'\1', doc) + doc = re.sub(r'``(.+)``', r'`\1`', doc) + doc = doc.replace('\n', ' ') + doc = doc.replace('. ', '. ') + if variable == 'CMAKE_MATCH_': + for i in range(10): + self._builtin_variables[f'CMAKE_MATCH_{i}'] = doc + elif '<' in variable: + variable = re.sub(r'<[^>]+>', r'[^_]+', variable) + pattern = re.compile(variable) + self._builtin_variable_template[pattern] = doc + else: + self._builtin_variables[variable] = doc + + def get_command_doc(self, command: str) -> Optional[str]: + return self._builtin_commands.get(command) + + def search_command(self, command: str) -> List[str]: + command = command.lower() + return [x for x in self._builtin_commands if x.startswith(command)] + + def get_variable_doc(self, variable: str) -> Optional[str]: + doc = self._cached_variables.get(variable) + if doc: + return doc + return self._builtin_variables.get(variable) + + def search_variable(self, variable: str) -> List[str]: + cached = frozenset(x for x in self._cached_variables + if x.startswith(variable)) + builtin = frozenset(x for x in self._builtin_variables + if x.startswith(variable)) + return list(cached | builtin) + + def search_target(self, target: str) -> List[str]: + return [x for x in self._targets if x.startswith(target)] + + def _truncate_variable(self, v: str) -> str: + width = 70 + return v[:width] + (v[width:] and '...') diff --git a/src/cmake_language_server/formatter.py b/src/cmake_language_server/formatter.py new file mode 100644 index 0000000..498feb9 --- /dev/null +++ b/src/cmake_language_server/formatter.py @@ -0,0 +1,116 @@ +from typing import List + +from .parser import TokenList + + +class Formatter(object): + indnt: str + lower_identifier: bool + + def __init__(self, indent=' ', lower_identifier=True): + self.indent = indent + self.lower_identifier = lower_identifier + + def format(self, tokens: TokenList) -> str: + cmds: List[str] = [''] + indnet_level = 0 + for token in tokens: + if isinstance(token, tuple): + raw_identifier = token[0] + identifier = raw_identifier.lower() + if identifier in ('elseif', 'else', 'endif', 'endforeach', + 'endwhile', 'endmacro', 'endfunction'): + if indnet_level > 0: + indnet_level -= 1 + cmds[-1] = self.indent * indnet_level + cmds[-1] += (identifier + if self.lower_identifier else raw_identifier) + args = self._format_args(token[1]) + if len(args) < 2: + cmds[-1] += '(' + ''.join(args) + ')' + else: + cmds[-1] += '(\n' + for arg in args: + cmds[-1] += self.indent * (indnet_level + + 1) + arg + '\n' + cmds[-1] += self.indent * indnet_level + ')' + if identifier in ('if', 'elseif', 'else', 'foreach', 'while', + 'macro', 'function'): + indnet_level += 1 + elif token == '\n': + cmds.append('') + elif token[0] == '#': + if cmds[-1]: + cmds[-1] += token + else: + cmds[-1] = self.indent * indnet_level + token + elif cmds[-1]: + cmds[-1] += token + + cmds = self._strip_line(cmds) + return '\n'.join(cmds) + '\n' + + def _format_args(self, args: List[str]) -> List[str]: + lines = [''] + for i in range(len(args)): + arg = args[i] + if arg[0] == '#': + lines[-1] += arg + elif arg[0] == '\n': + lines.append('') + elif arg.isspace(): + if lines[-1]: + if i + 1 < len(args) and args[i + 1][0] == '#': + lines[-1] += arg + else: + lines[-1] += ' ' + else: + lines[-1] += arg + + return self._strip_line(lines) + + def _strip_line(self, lines: List[str]) -> List[str]: + '''Delete empty lines at the start/end of the input''' + + ret: List[str] = [] + for line in lines: + line = line.rstrip() + if line != '' or len(ret) > 0: + ret.append(line) + while ret and ret[-1] == '': + del ret[-1] + return ret + + +def main(args: List[str] = None): + from pathlib import Path + from argparse import ArgumentParser + from .parser import ListParser + + parser = ArgumentParser() + parser.add_argument('lists', type=Path, nargs='*', help='CMake list files') + parser.add_argument('-i', + '--inplace', + action='store_true', + help='inplace edit') + + args = parser.parse_args(args) + + list_parser = ListParser() + formatter = Formatter() + for listpath in args.lists: + with listpath.open() as fp: + content = fp.read() + tokens, remain = list_parser.parse(content) + if remain: + if args.inplace: + pass + else: + print(content, end='') + else: + formated = formatter.format(tokens) + if args.inplace: + with listpath.open('w') as fp: + fp.write(formated) + else: + print(formated, end='') diff --git a/src/cmake_language_server/parser.py b/src/cmake_language_server/parser.py new file mode 100644 index 0000000..8188a3b --- /dev/null +++ b/src/cmake_language_server/parser.py @@ -0,0 +1,63 @@ +from typing import List, Tuple, Union + +import pyparsing as pp + +CommandTokenType = Tuple[str, List[str]] +TokenType = Union[str, CommandTokenType] +TokenList = List[TokenType] + + +class ListParser(object): + _parser: pp.ParserElement + + def __init__(self): + newline = '\n' + space_plus = pp.Regex('[ \t]+') + space_star = pp.Optional(space_plus) + + quoted_element = pp.Regex(r'[^\\"]|\\[^A-Za-z0-9]|\\[trn]') + quoted_argument = pp.Combine('"' + pp.ZeroOrMore(quoted_element) + '"') + + bracket_content = pp.Forward() + + def action_bracket_open(tokens: pp.ParseResults): + nonlocal bracket_content + marker = ']' + '=' * (len(tokens[0]) - 2) + ']' + bracket_content <<= pp.SkipTo(marker, include=True) + + bracket_open = pp.Regex(r'\[=*\[').setParseAction(action_bracket_open) + bracket_argument = pp.Combine(bracket_open + bracket_content) + + unquoted_element = pp.Regex(r'[^\s()#"\\]|\\[^A-Za-z0-9]|\\[trn]') + unquoted_argument = pp.Combine(pp.OneOrMore(unquoted_element)) + + argument = bracket_argument | quoted_argument | unquoted_argument + + line_comment = pp.Combine('#' + ~bracket_open + + pp.SkipTo(pp.LineEnd())) + bracket_comment = pp.Combine('#' + bracket_argument) + line_ending = (space_star + + pp.ZeroOrMore(bracket_comment + space_star) + + pp.Optional(line_comment) + (newline | pp.lineEnd)) + + identifier = pp.Word(pp.alphas + '_', pp.alphanums + '_') + arguments = pp.Forward() + arguments << pp.ZeroOrMore(argument | line_ending | space_plus + | '(' + arguments + ')').leaveWhitespace() + arguments = pp.Group(arguments) + PAREN_L, PAREN_R = map(pp.Suppress, '()') + command_invocation = ( + identifier + space_star.suppress() + PAREN_L + arguments + + PAREN_R).setParseAction(lambda t: (t[0], t[1].asList())) + + file_element = (space_star + command_invocation + line_ending + | line_ending).leaveWhitespace() + file = pp.ZeroOrMore(file_element) + + self._parser = file + + def parse(self, liststr: str) -> Tuple[TokenList, str]: + for t, s, e in self._parser.scanString(liststr, maxMatches=1): + if s == 0: + return t.asList(), liststr[e:] + return [], liststr diff --git a/src/cmake_language_server/server.py b/src/cmake_language_server/server.py new file mode 100644 index 0000000..b95c0b8 --- /dev/null +++ b/src/cmake_language_server/server.py @@ -0,0 +1,138 @@ +import logging +import re +from pathlib import Path +from typing import List, Optional, Tuple + +from pygls.features import (COMPLETION, FORMATTING, HOVER, INITIALIZE, + INITIALIZED, TEXT_DOCUMENT_DID_SAVE) +from pygls.server import LanguageServer +from pygls.types import (CompletionItem, CompletionItemKind, CompletionList, + CompletionParams, CompletionTriggerKind, + DocumentFormattingParams, Hover, InitializeParams, + MarkupContent, MarkupKind, Position, Range, + TextDocumentPositionParams, TextEdit) + +from .api import API +from .formatter import Formatter +from .parser import ListParser + +logger = logging.getLogger(__name__) + + +class CMakeLanguageServer(LanguageServer): + _parser: ListParser + _api: API + + def __init__(self): + super().__init__() + + self._parser = ListParser() + self._api = None + + @self.feature(INITIALIZE) + def initialize(params: InitializeParams): + opts = params.initializationOptions + + cmake = getattr(opts, 'cmakeExecutable', 'cmake') + builddir = getattr(opts, 'buildDirectory', '') + logging.info(f'cmakeExecutable={cmake}, buildDirectory={builddir}') + + self._api = API(cmake, Path(builddir)) + self._api.parse_doc() + + @self.feature(COMPLETION, trigger_characters=['{']) + def completions(params: CompletionParams): + if (params.context.triggerKind == + CompletionTriggerKind.TriggerCharacter): + token = '' + trigger = params.context.triggerCharacter + else: + ret = self.cursor_word(params.textDocument.uri, + params.position, False) + if not ret: + return None + token = ret[0] + trigger = None + + items: List[CompletionItem] = [] + + if trigger != '{': + commands = self._api.search_command(token) + items.extend( + CompletionItem(x, + CompletionItemKind.Function, + documentation=self._api.get_command_doc(x)) + for x in commands) + + variables = self._api.search_variable(token) + items.extend( + CompletionItem(x, + CompletionItemKind.Variable, + documentation=self._api.get_variable_doc(x)) + for x in variables) + + if trigger != '{': + targets = self._api.search_target(token) + items.extend( + CompletionItem(x, CompletionItemKind.Class) + for x in targets) + + return CompletionList(False, items) + + @self.feature(FORMATTING) + def formatting(params: DocumentFormattingParams): + doc = self.workspace.get_document(params.textDocument.uri) + content = doc.source + tokens, remain = self._parser.parse(content) + if remain: + self.show_message('CMake parser failed') + return None + + formatted = Formatter().format(tokens) + lines = content.count('\n') + return [ + TextEdit(Range(Position(0, 0), Position(lines + 1, 0)), + formatted) + ] + + @self.feature(HOVER) + def hover(params: TextDocumentPositionParams): + ret = self.cursor_word(params.textDocument.uri, params.position) + if not ret: + return None + doc = self._api.get_command_doc(ret[0].lower()) + if not doc: + doc = self._api.get_variable_doc(ret[0]) + if not doc: + return None + return Hover(MarkupContent(MarkupKind.Markdown, doc), ret[1]) + + @self.thread() + @self.feature(TEXT_DOCUMENT_DID_SAVE, includeText=False) + @self.feature(INITIALIZED) + def run_cmake(*args): + if self._api.query(): + self._api.read_reply() + + def cursor_word(self, + uri: str, + position: Position, + include_all: bool = True) -> Optional[Tuple[str, Range]]: + doc = self.workspace.get_document(uri) + content = doc.source + line = content.split('\n')[position.line] + cursor = position.character + for m in re.finditer(r'\w+', line): + if m.start() <= cursor <= m.end(): + end = m.end() if include_all else cursor + return (line[m.start():end], + Range(Position(position.line, m.start()), + Position(position.line, end))) + + return None + + +def main(): + logging.basicConfig(level=logging.INFO) + logging.getLogger('pygls').setLevel(logging.WARNING) + CMakeLanguageServer().start_io() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b042aad --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +import logging + +import pytest + + +@pytest.fixture() +def cmake_build(shared_datadir): + from subprocess import run + source = shared_datadir / 'cmake' + build = source / 'build' + build.mkdir() + p = run(['cmake', '-S', source, '-B', build], + check=True, + capture_output=True, + universal_newlines=True) + logging.debug(p.stdout) + logging.debug(p.stderr) + yield build diff --git a/tests/data/cmake/CMakeLists.txt b/tests/data/cmake/CMakeLists.txt new file mode 100644 index 0000000..f9fc0fa --- /dev/null +++ b/tests/data/cmake/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.10) + +project(testproject CXX) + +add_executable(test_app main.cpp) + +add_library(test_lib lib.cpp) diff --git a/tests/data/cmake/lib.cpp b/tests/data/cmake/lib.cpp new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/cmake/main.cpp b/tests/data/cmake/main.cpp new file mode 100644 index 0000000..44e82e2 --- /dev/null +++ b/tests/data/cmake/main.cpp @@ -0,0 +1 @@ +int main(int argc, char *argv[]) { return 0; } diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..e46d684 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,73 @@ +import subprocess + +from cmake_language_server.api import API + + +def test_query_with_cache(cmake_build): + api = API('cmake', cmake_build) + assert api.query() + + query = cmake_build / '.cmake' / 'api' / 'v1' / 'query' + assert query.exists() + + reply = cmake_build / '.cmake' / 'api' / 'v1' / 'reply' + assert reply.exists() + + +def test_query_without_cache(cmake_build): + api = API('cmake', cmake_build) + (cmake_build / 'CMakeCache.txt').unlink() + + assert not api.query() + + +def test_read_variable(cmake_build): + api = API('cmake', cmake_build) + assert api.query() + assert api.read_reply() + + assert api.get_variable_doc('testproject_BINARY_DIR') + + +def test_read_cmake_files(cmake_build): + api = API('cmake', cmake_build) + api.parse_doc() + assert api.query() + api.read_reply() + + assert 'GNU' in api.get_variable_doc('CMAKE_CXX_COMPILER_ID') + + +def test_parse_commands(cmake_build): + api = API('cmake', cmake_build) + api.parse_doc() + + p = subprocess.run(['cmake', '--help-command-list'], + capture_output=True, + universal_newlines=True) + commands = p.stdout.strip().split('\n') + + for command in commands: + assert api.get_command_doc(command) is not None, f'{command} not found' + + assert 'break()' in api.get_command_doc('break') + assert api.get_command_doc('not_existing_command') is None + + +def test_parse_variables(cmake_build): + api = API('cmake', cmake_build) + api.parse_doc() + + p = subprocess.run(['cmake', '--help-variable-list'], + capture_output=True, + universal_newlines=True) + variables = p.stdout.strip().split('\n') + + for variable in variables: + if '<' in variable: + continue + assert api.get_variable_doc( + variable) is not None, f'{variable} not found' + + assert api.get_variable_doc('BUILD_SHARED_LIBS') is not None + assert api.get_variable_doc('not_existing_variable') is None diff --git a/tests/test_fomatter.py b/tests/test_fomatter.py new file mode 100644 index 0000000..539f8fa --- /dev/null +++ b/tests/test_fomatter.py @@ -0,0 +1,74 @@ +from cmake_language_server.formatter import Formatter +from cmake_language_server.parser import ListParser + + +def make_formatter_test(liststr: str, expect: str): + def test(): + tokens, remain = ListParser().parse(liststr) + actual = Formatter().format(tokens) + assert actual == expect + + return test + + +test_command = make_formatter_test('a()', 'a()\n') +test_command_tolower = make_formatter_test('A()', 'a()\n') +test_remove_space = make_formatter_test(''' + #a + b ( c ) # d +''', '''\ +#a +b(c) # d +''') +test_indent_if = make_formatter_test( + ''' +if() +a() # a + else() +# b +b() +endif() +''', '''\ +if() + a() # a +else() + # b + b() +endif() +''') +test_indent_if_nested = make_formatter_test( + ''' +if() +if() +a() +b() +endif() +endif() +''', '''\ +if() + if() + a() + b() + endif() +endif() +''') +test_argument = make_formatter_test('a( b c d)', 'a(b c d)\n') +test_argument_multiline = make_formatter_test( + ''' +if() +a(b c +d # e +f +# g +) # h +endif() +''', '''\ +if() + a( + b c + d # e + f + # g + ) # h +endif() +''') diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..d8b521c --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,64 @@ +from typing import List + +from cmake_language_server.parser import ListParser, TokenType + + +def make_parser_test(liststr: str, + expect_token: List[TokenType], + expect_remain: str = ''): + def test(): + actual_token, actual_remain = ListParser().parse(liststr) + assert actual_token == expect_token + assert actual_remain == expect_remain + + return test + + +test_command_no_args = make_parser_test('a()', [('a', [])]) +test_command_space = make_parser_test(' a ()', [' ', ('a', [])]) +test_command_arg = make_parser_test('a(b)', [('a', ['b'])]) +test_command_arg_space = make_parser_test('a ( b )', [('a', ['b'])]) +test_command_arg_escape = make_parser_test(r'a(\n\")', [('a', [r'\n\"'])]) +test_command_arg_paren = make_parser_test('a((b))', [('a', ['(', 'b', ')'])]) +test_command_arg_paren_paren = make_parser_test( + 'a(((b)))', [('a', ['(', '(', 'b', ')', ')'])]) +test_command_arg_quote = make_parser_test(r'a("b\"")', [('a', [r'"b\""'])]) +test_command_arg_quote_cont = make_parser_test('a("\\\n")', + [('a', ['"\\\n"'])]) +test_command_arg_quo_multiline = make_parser_test('''a("b +c +")''', [('a', ['"b\nc\n"'])]) +test_command_arg_bracket_0 = make_parser_test('a([[b]])', [('a', ['[[b]]'])]) +test_command_arg_bracket_1 = make_parser_test('a([=[b]=])', + [('a', ['[=[b]=]'])]) +test_command_arg_space = make_parser_test('a ( b )', [('a', [' ', 'b', ' '])]) +test_command_arg_multi = make_parser_test('a(b c)', [('a', ['b', ' ', 'c'])]) +test_command_multielement = make_parser_test('''a( + b + c # c +)''', [('a', ['\n', ' ', 'b', '\n', ' ', 'c', ' ', '# c', '\n'])]) +test_line_comment = make_parser_test('a() # b # c', + [('a', []), ' ', '# b # c']) +test_bracket_comment = make_parser_test('#[[a]]#[[b]]', ['#[[a]]', '#[[b]]']) +test_bracket_comment_nested = make_parser_test('#[=[[[a]]]=]', + ['#[=[[[a]]]=]']) +test_bracket_comment_multiline = make_parser_test('#[[\na\nb\nc\n]]', + ['#[[\na\nb\nc\n]]']) +test_if_block = make_parser_test('''if() + a() +else() + b() +endif()''', [('if', []), '\n', ' ', ('a', []), '\n', ('else', []), '\n', ' ', + ('b', []), '\n', ('endif', [])]) +test_comment_multi_linecomment = make_parser_test( + '''a()# a +b() # b +c() # c''', [('a', []), '# a', '\n', ('b', []), ' ', '# b', '\n', + ('c', []), ' ', '# c']) + +test_incomplete_id = make_parser_test('a', [], 'a') +test_incomplete_command = make_parser_test('a(', [], 'a(') +test_incomplete_id_after_command = make_parser_test('a()\nb', + [('a', []), '\n'], 'b') +test_incomplete_command_after_command = make_parser_test( + 'a()\nb(c', [('a', []), '\n'], 'b(c') diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..f1b990c --- /dev/null +++ b/tox.ini @@ -0,0 +1,21 @@ +[tox] +isolated_build = True +skipsdist = True +envlist = py36, py37, py38, lint + +[testenv] +whitelist_externals = poetry +skip_install = true +commands_pre = + poetry install +commands = + poetry run pytest -sv tests + +[testenv:lint] +whitelist_externals = poetry +skip_install = true +commands = + poetry run isort -c -rc src tests + poetry run yapf -d -r src tests + poetry run flake8 + poetry run mypy src tests