diff --git a/.editorconfig b/.editorconfig index ddead42..8aab655 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,6 +6,10 @@ max_line_length = 120 [*.rs] indent_size = 4 +[*.py] +indent_size = 4 +indent_style = space + [*.{md,yml,yaml}] indent_style = space indent_size = 2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a71207f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,58 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + e2e-testing: + strategy: + fail-fast: false + matrix: + runs-on: + - ubuntu-latest + - macos-latest + - windows-latest + runs-on: ${{ matrix.runs-on }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Create dummy tag + run: git tag -f CI + + - name: Run unit tests + id: cargo-test + continue-on-error: true + run: cargo test + + - name: Run tests + working-directory: testing + run: | + pip install uv + uv venv + . ${{ matrix.runs-on == 'windows-latest' && '.venv/Scripts/activate' || '.venv/bin/activate'}} + uv pip sync requirements.txt + pytest -vv --junitxml=.results.xml --color=yes + + - name: Report failures + if: always() + uses: pmeier/pytest-results-action@main + with: + path: testing/.results.xml + summary: true + fail-on-empty: true + title: E2E Test Results + + - name: Fail if unit tests failed + if: steps.cargo-test.outcome == 'failure' + run: exit 1 diff --git a/.gitignore b/.gitignore index e96ecf3..8657f82 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ out/ dist/* *.vsix syntaxes/*.json +.venv +__pycache__ +.*_cache \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index b6f8921..b498cb4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,6 +2,18 @@ { "version": "0.2.0", "configurations": [ + { + "name": "E2E Tests", + "type": "debugpy", + "request": "launch", + "program": ".venv/bin/pytest", + "args": ["-v"], + "cwd": "${workspaceFolder}/testing", + "console": "integratedTerminal", + "windows": { + "program": ".venv/Scripts/pytest.exe" + } + }, { "type": "lldb", "request": "attach", diff --git a/.vscode/settings.json b/.vscode/settings.json index b69c959..8670db2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,5 @@ }, // Turn off tsc task auto detection since we have the necessary tasks as npm scripts "typescript.tsc.autoDetect": "off", - "typescript.tsdk": "node_modules/typescript/lib", - "python.languageServer": "None" + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/Cargo.lock b/Cargo.lock index 220a5cb..181a5bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,54 +44,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "anstream" -version = "0.6.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" - -[[package]] -name = "anstyle-parse" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" -dependencies = [ - "windows-sys 0.52.0", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" -dependencies = [ - "anstyle", - "windows-sys 0.52.0", -] - [[package]] name = "async-trait" version = "0.1.80" @@ -217,12 +169,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "colorchoice" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" - [[package]] name = "console" version = "0.15.8" @@ -487,29 +433,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "env_filter" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a009aa4810eb158359dda09d0c87378e4bbb89b5a801f016885a4707ba24f7ea" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b35839ba51819680ba087cd351788c9a3c476841207e0b8cee0b04722343b9" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "humantime", - "log", -] - [[package]] name = "equivalent" version = "1.0.1" @@ -750,8 +673,8 @@ dependencies = [ "aho-corasick", "bstr", "log", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.6", + "regex-syntax 0.8.3", ] [[package]] @@ -805,6 +728,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + [[package]] name = "http" version = "0.2.12" @@ -839,12 +768,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humantime" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" - [[package]] name = "hyper" version = "0.14.28" @@ -916,7 +839,7 @@ dependencies = [ "globset", "log", "memchr", - "regex-automata", + "regex-automata 0.4.6", "same-file", "walkdir", "winapi-util", @@ -1074,6 +997,15 @@ dependencies = [ "url", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "memchr" version = "2.7.2" @@ -1161,12 +1093,32 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.2" @@ -1211,7 +1163,6 @@ dependencies = [ "const_format", "dashmap", "derive_more", - "env_logger", "fomat-macros", "futures", "git-version", @@ -1220,7 +1171,6 @@ dependencies = [ "intmap", "lasso", "libflate", - "log", "miette", "num_enum", "pathdiff", @@ -1238,6 +1188,8 @@ dependencies = [ "tokio", "tower", "tower-lsp", + "tracing", + "tracing-subscriber", "tree-sitter", "tree-sitter-javascript", "tree-sitter-python", @@ -1295,6 +1247,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "owo-colors" version = "4.0.0" @@ -1558,8 +1516,17 @@ checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.6", + "regex-syntax 0.8.3", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -1570,9 +1537,15 @@ checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.3", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.3" @@ -1882,6 +1855,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "signature" version = "2.2.0" @@ -2102,6 +2084,16 @@ dependencies = [ "syn 2.0.60", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.3.36" @@ -2146,6 +2138,7 @@ dependencies = [ "bytes", "libc", "mio", + "num_cpus", "pin-project-lite", "socket2", "tokio-macros", @@ -2306,6 +2299,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -2453,10 +2476,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] -name = "utf8parse" -version = "0.2.1" +name = "valuable" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" [[package]] name = "vcpkg" @@ -2583,6 +2606,22 @@ version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.8" @@ -2592,6 +2631,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/Cargo.toml b/Cargo.toml index 1dae44a..db17556 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,14 +43,12 @@ pkg-fmt = "zip" tree-sitter = "0.20.10" [dependencies] -env_logger = "0.11.3" ropey = "1.5.0" serde_json = "1.0.108" -tokio = { version = "1.17.0", features = ["macros", "rt", "fs", "io-std"] } +tokio = { version = "1.17.0", features = ["macros", "rt-multi-thread", "fs", "io-std"] } tower-lsp = { version = "0.20.0", features = ["proposed"] } serde = { version = "1.0", features = ["derive"] } dashmap = "5.1.0" -log = "0.4.14" globwalk = "0.9.1" miette = { version = "7.2.0", features = ["fancy"] } futures = "0.3.28" @@ -77,6 +75,8 @@ thiserror = "1.0.58" git-version = "0.3.9" smart-default = "0.7.1" const_format = { version = "0.2.32", features = ["assertcp"] } +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } [target.'cfg(all(target_os = "linux", any(not(target_env = "gnu"), not(target_pointer_width = "64"))))'.dependencies] self_update = { version = "0.39.0", default-features = false, features = ["archive-tar", "archive-zip", "compression-flate2", "compression-zip-deflate", "rustls"] } diff --git a/README.md b/README.md index 553add1..37a0e70 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # odoo-lsp [![nightly](https://github.com/Desdaemon/odoo-lsp/actions/workflows/nightly.yml/badge.svg)](https://github.com/Desdaemon/odoo-lsp/actions/workflows/nightly.yml) +[![test](https://github.com/Desdaemon/odoo-lsp/actions/workflows/test.yml/badge.svg)](https://github.com/Desdaemon/odoo-lsp/actions/workflows/test.yml) ## Features diff --git a/client/src/extension.ts b/client/src/extension.ts index fdc5e06..2b35e89 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -298,24 +298,35 @@ export async function activate(context: vscode.ExtensionContext) { const logLevel = vscode.workspace.getConfiguration("odoo-lsp.trace").get("binary"); const RUST_LOG_STYLE = "never"; + const NO_COLOR = "1"; const serverOptions: ServerOptions = { run: { command, options: { - env: { ...process.env, RUST_LOG: process.env.RUST_LOG || `info,odoo_lsp=${logLevel}`, RUST_LOG_STYLE }, + env: { + ...process.env, + RUST_LOG: process.env.RUST_LOG || `info,odoo_lsp=${logLevel}`, + RUST_LOG_STYLE, + NO_COLOR, + }, }, }, debug: { command, options: { - env: { ...process.env, RUST_LOG: process.env.RUST_LOG || `debug,odoo_lsp=${logLevel}`, RUST_LOG_STYLE }, + env: { + ...process.env, + RUST_LOG: process.env.RUST_LOG || `debug,odoo_lsp=${logLevel}`, + RUST_LOG_STYLE, + NO_COLOR, + }, }, }, }; const binaryOutputChannel = vscode.window.createOutputChannel("Odoo LSP", { log: true }); - const logPattern = /^(INFO|WARN|DEBUG|ERROR|TRACE)([^\]]*?)\]/; - const splitPattern = /^\[/gm; + const logPattern = /^ (INFO|WARN|DEBUG|ERROR|TRACE) ([^\n]*)$/; + const splitPattern = /\n/gm; const oldAppend = binaryOutputChannel.append.bind(binaryOutputChannel); binaryOutputChannel.append = function (this: vscode.LogOutputChannel, lines: string) { diff --git a/examples/two/test.py b/examples/two/test.py index 447ec48..07c785b 100644 --- a/examples/two/test.py +++ b/examples/two/test.py @@ -1,30 +1,36 @@ -self.env.ref('one.one') +self.env.ref("one.one") + class Foo(Model): pass + class Bar(Model): - _name = 'bar' - _description = 'asd' + _name = "bar" + _description = "asd" barr = Boolean() bark = Char() - food = Many2one('quux') + food = Many2one("quux") + class Baz(models.Model): - _inherit = 'moo' + _inherit = "moo" def what(): pass + class Quux(models.Model): - _name = 'quux' - _inherit = 'bar' + _name = "quux" + _inherit = "bar" + class Moo(models.Model): - _name = 'moo' - _inherit = 'quux' + _name = "moo" + _inherit = "quux" + + hahar = fields.Many2one(comodel_name="moo") - hahar = fields.Many2one(comodel_name='moo') def foo(self): - self.env['moo'].hahar - request.render('generic_tax_report') + self.env["moo"].hahar + request.render("generic_tax_report") diff --git a/package.json b/package.json index 7ee355c..11aec69 100644 --- a/package.json +++ b/package.json @@ -159,7 +159,7 @@ "test-compile": "tsc -p ./", "compile": "cross-env NODE_ENV=production tsc -b", "watch": "rm -rf dist && tsc -b -w", - "lint": "prettier --write . && cargo fmt && cargo clippy --fix --allow-dirty --allow-staged", + "lint": "prettier --write . && ruff format && cargo fmt && cargo clippy --fix --allow-dirty --allow-staged", "pretest": "npm run compile && npm run lint", "test": "node ./out/test/runTest.js", "build": "webpack --config webpack.config.js", diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..1f4ccc1 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,3 @@ +{ + "strict": ["**/test_*.py"] +} diff --git a/src/analyze.rs b/src/analyze.rs index e32cb46..8d770c2 100644 --- a/src/analyze.rs +++ b/src/analyze.rs @@ -3,7 +3,7 @@ use std::{borrow::Borrow, collections::HashMap, iter::FusedIterator, ops::ControlFlow}; -use log::trace; +use tracing::trace; use tree_sitter::{Node, QueryCursor}; use odoo_lsp::{ @@ -140,7 +140,7 @@ impl<'a> Iterator for Iter<'a> { #[rustfmt::skip] query! { #[derive(Debug)] - FieldCompletion(Name, SelfParam, Scope); + FieldCompletion(Name, SelfParam, Scope, Def); ((class_definition (block (expression_statement @@ -150,7 +150,7 @@ query! { (decorated_definition (function_definition (parameters . (identifier) @SELF_PARAM) (block) @SCOPE) .) - (function_definition (parameters . (identifier) @SELF_PARAM) (block) @SCOPE) ])) @class + (function_definition (parameters . (identifier) @SELF_PARAM) (block) @SCOPE)] @DEF)) @class (#match? @_name "^_(name|inherit)$")) } @@ -520,6 +520,7 @@ impl Backend { if !node.is_named() { continue; } + let _test = node.to_sexp(); if let Some(end) = scope_ends.last() { if node.start_byte() > *end { scope.exit(); @@ -564,13 +565,20 @@ pub fn determine_scope<'out, 'node>( Some(FieldCompletion::SelfParam) => { self_param = Some(capture.node); } - Some(FieldCompletion::Scope) if capture.node.byte_range().contains_end(offset) => { + Some(FieldCompletion::Def) => { + if !capture.node.byte_range().contains_end(offset) { + continue 'scoping; + } + } + Some(FieldCompletion::Scope) => { fn_scope = Some(capture.node); - break 'scoping; } - Some(FieldCompletion::Scope) | None => {} + None => {} } } + if fn_scope.is_some() { + break 'scoping; + } } let fn_scope = fn_scope?; let self_param = String::from_utf8_lossy(&contents[self_param?.byte_range()]); @@ -579,6 +587,9 @@ pub fn determine_scope<'out, 'node>( #[cfg(test)] mod tests { + use odoo_lsp::utils::position_to_offset; + use ropey::Rope; + use tower_lsp::lsp_types::Position; use tree_sitter::{Parser, QueryCursor}; use crate::analyze::FieldCompletion; @@ -618,11 +629,46 @@ class Foo(models.AbstractModel): matches!( &actual[..], [ - [None, None, Some(T::Name), Some(T::SelfParam), Some(T::Scope)], - [None, None, Some(T::Name), Some(T::SelfParam), Some(T::Scope)] + [ + None, + None, + Some(T::Name), + Some(T::Def), + Some(T::SelfParam), + Some(T::Scope) + ], + [ + None, + None, + Some(T::Name), + Some(T::Def), + Some(T::SelfParam), + Some(T::Scope) + ] ] ), "{actual:?}" ) } + + #[test] + fn test_determine_scope() { + let mut parser = Parser::new(); + parser.set_language(tree_sitter_python::language()).unwrap(); + let contents = r#" +class Foo(models.Model): + _name = 'foo' + def scope(self): + pass +"#; + let ast = parser.parse(contents, None).unwrap(); + let rope = Rope::from(contents); + let fn_start = position_to_offset(Position { line: 3, character: 1 }, &rope).unwrap(); + let fn_scope = ast + .root_node() + .named_descendant_for_byte_range(fn_start.0, fn_start.0) + .unwrap(); + super::determine_scope(ast.root_node(), contents.as_bytes(), fn_start.0) + .unwrap_or_else(|| panic!("{}", fn_scope.to_sexp())); + } } diff --git a/src/backend.rs b/src/backend.rs index a6e0bbd..f254295 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -4,7 +4,7 @@ //! This is the final destination in the flowchart. use std::borrow::Cow; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering::Relaxed; use std::sync::atomic::{AtomicBool, AtomicUsize}; @@ -12,13 +12,13 @@ use dashmap::{DashMap, DashSet}; use fomat_macros::fomat; use globwalk::FileType; use lasso::Spur; -use log::{debug, info, warn}; use miette::{diagnostic, miette}; use odoo_lsp::component::{Prop, PropDescriptor}; use ropey::Rope; use serde_json::Value; use tower_lsp::lsp_types::*; use tower_lsp::Client; +use tracing::{debug, info, instrument, warn}; use tree_sitter::{Parser, Tree}; use odoo_lsp::config::{CompletionsConfig, Config, ModuleConfig, ReferencesConfig, SymbolsConfig}; @@ -34,7 +34,7 @@ pub struct Backend { pub record_ranges: DashMap>, pub ast_map: DashMap, pub index: Index, - pub roots: DashSet, + pub roots: DashSet, pub capabilities: Capabilities, pub root_setup: CondVar, pub symbols_limit: AtomicUsize, @@ -47,6 +47,8 @@ pub struct Capabilities { pub dynamic_config: AtomicBool, /// Whether the client is expected to explicitly request for diagnostics. pub pull_diagnostics: AtomicBool, + /// Whether workspace/workspaceFolders can be called. + pub workspace_folders: AtomicBool, } pub struct TextDocumentItem { @@ -95,15 +97,15 @@ impl Backend { /// Maximum file line count to process diagnostics each on_change pub const DIAGNOSTICS_LINE_LIMIT: usize = 1200; - pub fn find_root_of(&self, path: &str) -> Option { + pub fn find_root_of(&self, path: &Path) -> Option { for root_ in self.roots.iter() { if path.starts_with(root_.key()) { - return Some(root_.key().to_string()); + return Some(root_.key().to_owned()); } } None } - + #[instrument(skip_all)] pub async fn on_change(&self, params: TextDocumentItem) -> miette::Result<()> { let split_uri = params.uri.path().rsplit_once('.'); let mut document = self @@ -120,10 +122,11 @@ impl Backend { // Rope updates are handled by did_change } } + let path = params.uri.to_file_path().unwrap(); let root = self - .find_root_of(params.uri.path()) + .find_root_of(&path) .ok_or_else(|| miette!("file not under any root"))?; - let root = interner().get_or_intern(&root); + let root = interner().get_or_intern_path(&root); let rope = document.rope.clone(); let eager_diagnostics = self.eager_diagnostics(params.open, &rope); match (split_uri, params.language) { @@ -162,10 +165,9 @@ impl Backend { let uri = params.text_document.uri; debug!("{}", uri.path()); let (_, extension) = uri.path().rsplit_once('.').ok_or_else(|| diagnostic!("no extension"))?; - let root = self - .find_root_of(uri.path()) - .ok_or_else(|| diagnostic!("out of root"))?; - let root = interner().get_or_intern(&root); + let path = uri.to_file_path().unwrap(); + let root = self.find_root_of(&path).ok_or_else(|| diagnostic!("out of root"))?; + let root = interner().get_or_intern_path(&root); let mut document = self .document_map @@ -181,17 +183,22 @@ impl Backend { Ok(()) } pub async fn did_save_python(&self, uri: Url, root: Spur, document: &mut Document) { - let path = uri.path(); + let path = uri.to_file_path().unwrap(); let zone = document.damage_zone.take(); let rope = &document.rope; let text = Cow::from(rope); _ = self - .update_models(Text::Full(text.into_owned()), path, root, rope.clone()) + .update_models(Text::Full(text.into_owned()), &path, root, rope.clone()) .await .inspect_err(|err| warn!("{err:?}")); if zone.is_some() { debug!("diagnostics"); - self.diagnose_python(path, &document.rope.clone(), zone, &mut document.diagnostics_cache); + self.diagnose_python( + uri.path(), + &document.rope.clone(), + zone, + &mut document.diagnostics_cache, + ); self.client .publish_diagnostics(uri, document.diagnostics_cache.clone(), None) .await; @@ -206,6 +213,7 @@ impl Backend { pub fn eager_diagnostics(&self, open: bool, rope: &Rope) -> bool { !self.capabilities.pull_diagnostics.load(Relaxed) && (open || rope.len_lines() < Self::DIAGNOSTICS_LINE_LIMIT) } + #[instrument(skip_all, fields(uri))] pub fn update_ast( &self, text: &Text, @@ -472,9 +480,10 @@ impl Backend { } pub fn jump_def_xml_id(&self, cursor_value: &str, uri: &Url) -> miette::Result> { let mut value = Cow::from(cursor_value); + let path = some!(uri.to_file_path().ok()); if !value.contains('.') { 'unscoped: { - if let Some(module) = self.index.module_of_path(Path::new(uri.path())) { + if let Some(module) = self.index.module_of_path(&path) { value = format!("{}.{value}", interner().resolve(&module)).into(); break 'unscoped; } @@ -785,10 +794,10 @@ impl Backend { }; for dir_entry in glob { let Ok(root) = dir_entry else { continue }; - self.roots.insert(root.path().to_string_lossy().to_string()); + self.roots.insert(root.path().to_owned()); } } else if tokio::fs::try_exists(&root).await.unwrap_or(false) { - self.roots.insert(root_display.to_string()); + self.roots.insert(root.clone()); } } // self.added_roots_root.store(true, Relaxed); @@ -825,8 +834,8 @@ impl Backend { } pub fn ensure_nonoverlapping_roots(&self) { let mut redundant = vec![]; - let mut roots = self.roots.iter().map(|r| r.to_string()).collect::>(); - roots.sort_unstable_by_key(|root| root.len()); + let mut roots = self.roots.iter().map(|r| r.as_path().to_owned()).collect::>(); + roots.sort_unstable_by_key(|root| root.as_os_str().len()); info!("{roots:?}"); for lhs in 1..roots.len() { for rhs in 0..lhs { @@ -847,7 +856,7 @@ impl Backend { ); } for root in redundant { - self.roots.remove(root); + self.roots.remove(Path::new(root)); } } } diff --git a/src/catch_panic.rs b/src/catch_panic.rs index 20852a0..1c5e86e 100644 --- a/src/catch_panic.rs +++ b/src/catch_panic.rs @@ -7,9 +7,9 @@ use std::task::{ready, Context, Poll}; use futures::FutureExt; use futures::{future::CatchUnwind, Future}; -use log::{error, warn}; use tower::Service; use tower_lsp::jsonrpc::{Error, ErrorCode, Id, Response}; +use tracing::{error, warn}; pub struct CatchPanic(pub S); impl Service for CatchPanic diff --git a/src/cli.rs b/src/cli.rs index ddbd9a1..db32e71 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,12 +1,12 @@ -use std::{env::current_dir, fs::canonicalize, io::stdout, path::Path, process::exit, sync::Arc}; +use std::{env::current_dir, fs::canonicalize, io::stdout, process::exit, sync::Arc}; use globwalk::FileType; -use log::{debug, warn}; use miette::{diagnostic, IntoDiagnostic}; use odoo_lsp::config::{CompletionsConfig, Config, ModuleConfig, ReferencesConfig, SymbolsConfig}; use odoo_lsp::index::{interner, Index}; use self_update::{backends::github, Status}; use serde_json::Value; +use tracing::{debug, warn}; mod tsconfig; @@ -126,8 +126,8 @@ pub async fn tsconfig(addons_path: &[&str], output: Option<&str>) -> miette::Res let index = Index::default(); for addons in &addons_path { - let path = addons.to_string_lossy(); - index.add_root(&path, None, true).await?; + // let path = addons.to_string_lossy(); + index.add_root(addons, None, true).await?; } let mut ts_paths = serde_json::Map::new(); @@ -142,8 +142,8 @@ pub async fn tsconfig(addons_path: &[&str], output: Option<&str>) -> miette::Res let mut outputs = tokio::task::JoinSet::new(); for entry in &index.roots { - let root = pathdiff::diff_paths(Path::new(entry.key().as_str()), &pwd) - .ok_or_else(|| diagnostic!("Cannot diff {} to pwd", entry.key()))?; + let root = pathdiff::diff_paths(entry.key(), &pwd) + .ok_or_else(|| diagnostic!("Cannot diff {:?} to pwd", entry.key()))?; includes.push(Value::String(root.join("**/static/src").to_string_lossy().into_owned())); type_roots.push(Value::String( @@ -163,7 +163,7 @@ pub async fn tsconfig(addons_path: &[&str], output: Option<&str>) -> miette::Res .into(), ); } - let scripts = globwalk::glob_builder(format!("{}/**/*.js", entry.key())) + let scripts = globwalk::glob_builder(entry.key().join("**/*.js").to_string_lossy()) .file_type(FileType::FILE | FileType::SYMLINK) .follow_links(true) .build() diff --git a/src/cli/tsconfig.rs b/src/cli/tsconfig.rs index ea6f1b0..b3d8317 100644 --- a/src/cli/tsconfig.rs +++ b/src/cli/tsconfig.rs @@ -2,9 +2,9 @@ use std::{path::PathBuf, sync::Arc}; use dashmap::DashMap; use lasso::Spur; -use log::debug; use miette::IntoDiagnostic; use odoo_lsp::{index::interner, utils::RangeExt, ImStr}; +use tracing::debug; use tree_sitter::{Parser, QueryCursor}; use ts_macros::query; diff --git a/src/index.rs b/src/index.rs index 49af1f1..987432f 100644 --- a/src/index.rs +++ b/src/index.rs @@ -8,20 +8,20 @@ use globwalk::FileType; use ignore::gitignore::Gitignore; use ignore::Match; use lasso::{Key, Spur, ThreadedRodeo}; -use log::{debug, info, warn}; use miette::{diagnostic, IntoDiagnostic}; use ropey::Rope; use smart_default::SmartDefault; use tower_lsp::lsp_types::notification::Progress; use tower_lsp::lsp_types::request::{ShowDocument, ShowMessageRequest}; use tower_lsp::lsp_types::*; +use tracing::{debug, info, warn}; use tree_sitter::QueryCursor; use ts_macros::query; use xmlparser::{Token, Tokenizer}; use crate::model::{Model, ModelIndex, ModelType}; use crate::record::Record; -use crate::utils::{ts_range_to_lsp_range, ByteOffset, ByteRange, RangeExt, Usage}; +use crate::utils::{path_contains, ts_range_to_lsp_range, ByteOffset, ByteRange, RangeExt, Usage}; use crate::{format_loc, ok, ImStr}; mod record; @@ -59,6 +59,9 @@ impl Interner { *self.get_counter().entry(out).or_default().value_mut() += 1; out } + pub fn get_or_intern_path(&self, path: &Path) -> Spur { + self.get_or_intern(path.to_string_lossy()) + } pub fn report_usage(&self) -> serde_json::Value { let items = self .get_counter() @@ -97,7 +100,7 @@ impl Interner { pub struct Index { /// root -> module key -> module's relpath to root #[default(_code = "DashMap::with_shard_amount(4)")] - pub roots: DashMap>, + pub roots: DashMap>, pub records: record::RecordIndex, pub templates: template::TemplateIndex, pub models: ModelIndex, @@ -137,7 +140,7 @@ impl Index { } pub async fn add_root( &self, - root: &str, + root: &Path, progress: Option<(&tower_lsp::Client, ProgressToken)>, tsconfig: bool, ) -> miette::Result> { @@ -146,27 +149,28 @@ impl Index { } let t0 = tokio::time::Instant::now(); + // let root = root.display(); let manifests = ok!( - globwalk::glob_builder(format!("{root}/**/__manifest__.py")) + globwalk::glob_builder(root.join("**/__manifest__.py").to_string_lossy()) .file_type(FileType::FILE | FileType::SYMLINK) .follow_links(true) .build(), - "Could not glob into {}", + "Could not glob into {:?}", root ); let mut gitignore = ignore::gitignore::GitignoreBuilder::new(root); gitignore .add(".gitignore") - .inspect(|err| warn!("error adding {root}/.gitignore: {err:?}")); + .inspect(|err| warn!("error adding {root:?}/.gitignore: {err:?}")); let gitignore = gitignore .build() - .inspect_err(|err| warn!("gitignore error for {root}: {err:?}")) + .inspect_err(|err| warn!("gitignore error for {root:?}: {err:?}")) .ok(); let mut module_count = 0; let mut outputs = tokio::task::JoinSet::new(); let interner = interner(); - let root_key = interner.get_or_intern(root); + let root_key = interner.get_or_intern(root.to_string_lossy()); for manifest in manifests { let manifest = manifest.into_diagnostic()?; let module_dir = manifest @@ -204,7 +208,7 @@ impl Index { let module_key = interner.get_or_intern(&module_name); let module_path = ok!( module_dir.strip_prefix(root), - "module_dir={:?} is not a subpath of root={}", + "module_dir={:?} is not a subpath of root={:?}", module_dir, root ); @@ -346,11 +350,11 @@ impl Index { elapsed: t0.elapsed(), })) } - pub fn remove_root(&self, root: &str) { + pub fn remove_root(&self, root: &Path) { self.roots.remove(root); for mut entry in self.records.iter_mut() { let module = interner().resolve(&entry.module); - if root.contains(module) { + if path_contains(root, module) { entry.deleted = true; } } diff --git a/src/index/symbol.rs b/src/index/symbol.rs index 2f3c489..6487df1 100644 --- a/src/index/symbol.rs +++ b/src/index/symbol.rs @@ -19,7 +19,7 @@ pub struct Symbol { /// An interned path split into (root, subpath) to save memory. #[derive(Clone, Copy, PartialEq, Eq)] -pub struct PathSymbol(pub Spur, pub Spur); +pub struct PathSymbol(Spur, Spur); impl PathSymbol { /// Panics if `root` is not a parent of `path`. diff --git a/src/main.rs b/src/main.rs index 2f3b897..756d550 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,7 +76,6 @@ use std::time::Duration; use catch_panic::CatchPanic; use dashmap::{DashMap, DashSet}; -use log::{debug, error, info, warn}; use ropey::Rope; use serde_json::Value; use tower::ServiceBuilder; @@ -85,6 +84,7 @@ use tower_lsp::lsp_types::notification::{DidChangeConfiguration, Notification, P use tower_lsp::lsp_types::request::WorkDoneProgressCreate; use tower_lsp::lsp_types::*; use tower_lsp::{LanguageServer, LspService, Server}; +use tracing::{debug, error, info, instrument, warn}; use odoo_lsp::config::Config; use odoo_lsp::index::{interner, Interner}; @@ -102,10 +102,16 @@ mod xml; pub use odoo_lsp::*; use backend::{Backend, Document, Language, Text}; +use tracing_subscriber::fmt::writer::MakeWriterExt; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::EnvFilter; #[tower_lsp::async_trait] impl LanguageServer for Backend { + #[instrument(skip_all)] async fn initialize(&self, params: InitializeParams) -> Result { + let _blocker = self.root_setup.block(); let root = params.root_uri.and_then(|uri| uri.to_file_path().ok()).or_else( #[allow(deprecated)] || params.root_path.map(PathBuf::from), @@ -132,7 +138,7 @@ impl LanguageServer for Backend { None }; let Some((path, config)) = &config_file else { - self.roots.insert(root.to_string_lossy().to_string()); + self.roots.insert(root.clone()); break 'root; }; match serde_json::from_slice::(config) { @@ -140,6 +146,14 @@ impl LanguageServer for Backend { Err(err) => error!("could not parse {:?}:\n{err}", path), } } + for ws_dir in params.workspace_folders.unwrap_or_default() { + match ws_dir.uri.to_file_path() { + Ok(path) => drop(self.roots.insert(path)), + Err(()) => { + error!("not a file path: {}", ws_dir.uri); + } + } + } } if let Some(WorkspaceClientCapabilities { @@ -148,10 +162,17 @@ impl LanguageServer for Backend { dynamic_registration: Some(true), }), .. - }) = params.capabilities.workspace + }) = params.capabilities.workspace.as_ref() { self.capabilities.dynamic_config.store(true, Relaxed); } + if let Some(WorkspaceClientCapabilities { + workspace_folders: Some(true), + .. + }) = params.capabilities.workspace.as_ref() + { + self.capabilities.workspace_folders.store(true, Relaxed); + } if let Some(TextDocumentClientCapabilities { diagnostic: Some(..), .. @@ -161,6 +182,61 @@ impl LanguageServer for Backend { self.capabilities.pull_diagnostics.store(true, Relaxed); } + let token = NumberOrString::String("odoo-lsp/postinit".to_string()); + let mut progress = None; + if self + .client + .send_request::(WorkDoneProgressCreateParams { token: token.clone() }) + .await + .is_ok() + { + _ = self + .client + .send_notification::(ProgressParams { + token: token.clone(), + value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(WorkDoneProgressBegin { + title: "Indexing".to_string(), + ..Default::default() + })), + }) + .await; + progress = Some((&self.client, token.clone())); + } + + self.ensure_nonoverlapping_roots(); + + for root in self.roots.iter() { + match self.index.add_root(&root, progress.clone(), false).await { + Ok(Some(results)) => { + info!( + target: "initialized", + "{} | {} modules | {} records | {} templates | {} models | {} components | {:.2}s", + root.display(), + results.module_count, + results.record_count, + results.template_count, + results.model_count, + results.component_count, + results.elapsed.as_secs_f64() + ); + } + Err(err) => { + error!("could not add root {}:\n{err}", root.display()); + } + _ => {} + } + } + + if progress.is_some() { + _ = self + .client + .send_notification::(ProgressParams { + token, + value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(Default::default())), + }) + .await; + } + Ok(InitializeResult { server_info: None, offset_encoding: None, @@ -200,9 +276,11 @@ impl LanguageServer for Backend { }, }) } + #[instrument(skip_all)] async fn shutdown(&self) -> Result<()> { Ok(()) } + #[instrument(skip(self))] async fn did_close(&self, params: DidCloseTextDocumentParams) { let path = params.text_document.uri.path(); let Self { @@ -226,66 +304,8 @@ impl LanguageServer for Backend { .publish_diagnostics(params.text_document.uri, vec![], None) .await; } + #[instrument(skip_all)] async fn initialized(&self, _: InitializedParams) { - let blocker = self.root_setup.block(); - - let token = NumberOrString::String("odoo-lsp/postinit".to_string()); - self.client - .send_request::(WorkDoneProgressCreateParams { token: token.clone() }) - .await - .expect("Could not create WDP"); - _ = self - .client - .send_notification::(ProgressParams { - token: token.clone(), - value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(WorkDoneProgressBegin { - title: "Indexing".to_string(), - ..Default::default() - })), - }) - .await; - let progress = Some((&self.client, token.clone())); - - for workspace in self.client.workspace_folders().await.unwrap().unwrap_or_default() { - let Ok(file_path) = workspace.uri.to_file_path() else { - continue; - }; - self.roots.insert(file_path.to_string_lossy().into_owned()); - } - self.ensure_nonoverlapping_roots(); - - for root in self.roots.iter() { - match self.index.add_root(root.as_str(), progress.clone(), false).await { - Ok(Some(results)) => { - info!( - target: "initialized", - "{} | {} modules | {} records | {} templates | {} models | {} components | {:.2}s", - root.as_str(), - results.module_count, - results.record_count, - results.template_count, - results.model_count, - results.component_count, - results.elapsed.as_secs_f64() - ); - } - Err(err) => { - error!("could not add root {}:\n{err}", root.as_str()); - } - _ => {} - } - } - - _ = self - .client - .send_notification::(ProgressParams { - token, - value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(Default::default())), - }) - .await; - - drop(blocker); - if self.capabilities.dynamic_config.load(Relaxed) { _ = self .client @@ -297,9 +317,9 @@ impl LanguageServer for Backend { .await; } } + #[instrument(skip_all, ret, fields(uri=params.text_document.uri.path()))] async fn did_open(&self, params: DidOpenTextDocumentParams) { - self.root_setup.wait().await; - + info!("{}", params.text_document.uri.path()); let language_id = params.text_document.language_id.as_str(); let split_uri = params.text_document.uri.path().rsplit_once('.'); let language = match (language_id, split_uri) { @@ -316,35 +336,28 @@ impl LanguageServer for Backend { self.document_map .insert(params.text_document.uri.path().to_string(), Document::new(rope.clone())); - if self - .index - .module_of_path(Path::new(params.text_document.uri.path())) - .is_none() - { + self.root_setup.wait().await; + let path = params.text_document.uri.to_file_path().unwrap(); + if self.index.module_of_path(&path).is_none() { // outside of root? debug!("oob: {}", params.text_document.uri.path()); - 'oob: { - let Ok(path) = params.text_document.uri.to_file_path() else { - break 'oob; - }; - let mut path = Some(path.as_path()); - while let Some(path_) = path { - if tokio::fs::try_exists(path_.with_file_name("__manifest__.py")) - .await - .unwrap_or(false) - { - if let Some(file_path) = path_.parent() { - let file_path = file_path.to_string_lossy(); - _ = self - .index - .add_root(&file_path, None, false) - .await - .inspect_err(|err| warn!("failed to add root {}:\n{err}", file_path)); - break; - } + let path = params.text_document.uri.to_file_path().ok(); + let mut path = path.as_deref(); + while let Some(path_) = path { + if tokio::fs::try_exists(path_.with_file_name("__manifest__.py")) + .await + .unwrap_or(false) + { + if let Some(file_path) = path_.parent() { + _ = self + .index + .add_root(file_path, None, false) + .await + .inspect_err(|err| warn!("failed to add root {}:\n{err}", file_path.display())); + break; } - path = path_.parent(); } + path = path_.parent(); } } @@ -360,6 +373,7 @@ impl LanguageServer for Backend { .await .inspect_err(|err| warn!("{err}")); } + #[instrument(skip_all)] async fn did_change(&self, mut params: DidChangeTextDocumentParams) { self.root_setup.wait().await; if let [TextDocumentContentChangeEvent { @@ -417,10 +431,12 @@ impl LanguageServer for Backend { .await .inspect_err(|err| warn!("{err}")); } + #[instrument(skip_all)] async fn did_save(&self, params: DidSaveTextDocumentParams) { self.root_setup.wait().await; _ = self.did_save_impl(params).await.inspect_err(|err| warn!("{err}")); } + #[instrument(skip_all)] async fn goto_definition(&self, params: GotoDefinitionParams) -> Result> { let uri = ¶ms.text_document_position_params.text_document.uri; debug!("goto_definition {}", uri.path()); @@ -447,6 +463,7 @@ impl LanguageServer for Backend { .flatten(); Ok(location.map(GotoDefinitionResponse::Scalar)) } + #[instrument(skip_all)] async fn references(&self, params: ReferenceParams) -> Result>> { let uri = ¶ms.text_document_position.text_document.uri; debug!("references {}", uri.path()); @@ -467,18 +484,20 @@ impl LanguageServer for Backend { Ok(refs.inspect_err(|err| warn!("{err}")).ok().flatten()) } + #[instrument(skip_all)] async fn completion(&self, params: CompletionParams) -> Result> { let uri = ¶ms.text_document_position.text_document.uri; - debug!("{}", uri.path()); + debug!("(completion) {}", uri.path()); let Some((_, ext)) = uri.path().rsplit_once('.') else { return Ok(None); // hit a directory, super unlikely }; + + self.root_setup.wait().await; let Some(document) = self.document_map.get(uri.path()) else { debug!("Bug: did not build a document for {}", uri.path()); return Ok(None); }; - self.root_setup.wait().await; if ext == "xml" { let completions = self.xml_completions(params, document.rope.clone()).await; match completions { @@ -512,6 +531,7 @@ impl LanguageServer for Backend { Ok(None) } } + #[instrument(skip_all)] async fn completion_resolve(&self, mut completion: CompletionItem) -> Result { 'resolve: { match &completion.kind { @@ -580,6 +600,7 @@ impl LanguageServer for Backend { } Ok(completion) } + #[instrument(skip_all)] async fn hover(&self, params: HoverParams) -> Result> { let uri = ¶ms.text_document_position_params.text_document.uri; let document = some!(self.document_map.get(uri.path())); @@ -601,12 +622,13 @@ impl LanguageServer for Backend { } } } + #[instrument(skip_all)] async fn did_change_configuration(&self, _: DidChangeConfigurationParams) { let items = self .roots .iter() .map(|entry| { - let scope_uri = Some(format!("file://{}", entry.key()).parse().unwrap()); + let scope_uri = Url::from_file_path(entry.key()).ok(); ConfigurationItem { section: Some("odoo-lsp".into()), scope_uri, @@ -626,22 +648,30 @@ impl LanguageServer for Backend { config.module.take(); self.on_change_config(config).await; } + #[instrument(skip_all)] async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) { for added in params.event.added { - let file_path = added.uri.to_file_path().expect("not a file path"); - let file_path = file_path.as_os_str().to_string_lossy(); - _ = self.index.add_root(&file_path, None, false).await.inspect_err(|err| { - warn!( - "(did_change_workspace_folders) failed to add root {}:\n{err}", - file_path - ) - }); + // let file_path = added.uri.path(); + let Ok(file_path) = added.uri.to_file_path() else { + error!("not a file path: {}", added.uri); + continue; + }; + _ = self + .index + .add_root(&file_path, None, false) + .await + .inspect_err(|err| warn!("failed to add root {}:\n{err}", file_path.display())); } for removed in params.event.removed { - self.index.remove_root(removed.uri.path()); + let Ok(file_path) = removed.uri.to_file_path() else { + error!("not a file path: {}", removed.uri); + continue; + }; + self.index.remove_root(&file_path); } self.index.mark_n_sweep(); } + #[instrument(skip_all)] async fn symbol(&self, params: WorkspaceSymbolParams) -> Result>> { let query = ¶ms.query; @@ -697,6 +727,7 @@ impl LanguageServer for Backend { Ok(Some(models.chain(records).take(limit).collect())) } } + #[instrument(skip_all)] async fn diagnostic(&self, params: DocumentDiagnosticParams) -> Result { let path = params.text_document.uri.path(); debug!("{path}"); @@ -725,6 +756,7 @@ impl LanguageServer for Backend { }, ))) } + #[instrument(skip_all)] async fn code_action(&self, params: CodeActionParams) -> Result> { let Some((_, "xml")) = params.text_document.uri.path().rsplit_once('.') else { return Ok(None); @@ -739,6 +771,7 @@ impl LanguageServer for Backend { }) .unwrap_or(None)) } + #[instrument(skip_all)] async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { match (params.command.as_str(), params.arguments.as_slice()) { ("goto_owl", [Value::String(_), Value::String(subcomponent)]) => { @@ -764,7 +797,7 @@ impl LanguageServer for Backend { } } -#[tokio::main(flavor = "current_thread")] +#[tokio::main(worker_threads = 2)] async fn main() { let outlog = std::env::var("ODOO_LSP_LOG").ok().map(|var| { let path = match var.as_str() { @@ -774,13 +807,19 @@ async fn main() { }; std::fs::File::create(path).unwrap() }); - env_logger::Builder::from_default_env() - .format_timestamp(None) - .format_indent(Some(2)) - .format_target(true) - .format_module_path(cfg!(debug_assertions)) - .target(env_logger::Target::Pipe(Box::new(FileTee::new(outlog)))) - .init(); + let registry = tracing_subscriber::registry().with(EnvFilter::from_default_env()); + let layer = tracing_subscriber::fmt::layer() + .compact() + .without_time() + .with_writer(std::io::stderr) + .with_file(true) + .with_line_number(true) + .with_target(cfg!(debug_assertions)); + if let Some(outlog) = outlog { + registry.with(layer.map_writer(|stderr| stderr.and(outlog))).init(); + } else { + registry.with(layer).init(); + } let args = std::env::args().collect::>(); let args = args.iter().skip(1).map(String::as_str).collect::>(); diff --git a/src/model.rs b/src/model.rs index c215406..9ff3ae7 100644 --- a/src/model.rs +++ b/src/model.rs @@ -9,13 +9,13 @@ use dashmap::mapref::one::RefMut; use dashmap::DashMap; use futures::executor::block_on; use lasso::Spur; -use log::{debug, error, info, trace, warn}; use miette::{diagnostic, Diagnostic, IntoDiagnostic}; use qp_trie::Trie; use rayon::prelude::{IntoParallelIterator, ParallelIterator}; use smart_default::SmartDefault; use tokio::sync::RwLock; use tower_lsp::lsp_types::Range; +use tracing::{debug, error, info, trace, warn}; use tree_sitter::{Node, Parser, QueryCursor}; use ts_macros::query; diff --git a/src/python.rs b/src/python.rs index 35ac7e7..e5de6ec 100644 --- a/src/python.rs +++ b/src/python.rs @@ -8,11 +8,11 @@ use std::path::Path; use std::sync::atomic::Ordering::Relaxed; use lasso::Spur; -use log::{debug, trace, warn}; use miette::{diagnostic, miette}; use odoo_lsp::index::{index_models, interner, PathSymbol}; use ropey::Rope; use tower_lsp::lsp_types::*; +use tracing::{debug, trace, warn}; use tree_sitter::{Node, Parser, QueryCursor, QueryMatch, Tree}; use odoo_lsp::model::{ModelName, ModelType, ResolveMappedError}; @@ -141,8 +141,8 @@ query! { (class_definition (block [ - (function_definition) @SCOPE - (decorated_definition (function_definition) @SCOPE) ])) + (function_definition (block) @SCOPE) + (decorated_definition (function_definition (block) @SCOPE)) ])) } /// (module (_)*) @@ -160,6 +160,7 @@ struct Mapped<'text> { } impl Backend { + #[tracing::instrument(skip_all, fields(uri))] pub fn on_change_python(&self, text: &Text, uri: &Url, rope: Rope, old_rope: Option) -> miette::Result<()> { let mut parser = Parser::new(); parser @@ -167,14 +168,14 @@ impl Backend { .expect("bug: failed to init python parser"); self.update_ast(text, uri, rope.clone(), old_rope, parser) } - pub async fn update_models(&self, text: Text, path: &str, root: Spur, rope: Rope) -> miette::Result<()> { + pub async fn update_models(&self, text: Text, path: &Path, root: Spur, rope: Rope) -> miette::Result<()> { let text = match text { Text::Full(text) => Cow::from(text), // TODO: Limit range of possible updates based on delta Text::Delta(_) => Cow::from(rope.slice(..)), }; let models = index_models(text.as_bytes())?; - let path = PathSymbol::strip_root(root, Path::new(path)); + let path = PathSymbol::strip_root(root, path); self.index.models.append(path, interner(), true, &models).await; for model in models { match model.type_ { @@ -208,10 +209,8 @@ impl Backend { warn!("invalid position {:?}", params.text_document_position.position); return Ok(None); }; - let Some(current_module) = self - .index - .module_of_path(Path::new(params.text_document_position.text_document.uri.path())) - else { + let path = some!(params.text_document_position.text_document.uri.to_file_path().ok()); + let Some(current_module) = self.index.module_of_path(&path) else { debug!("no current module"); return Ok(None); }; @@ -274,8 +273,13 @@ impl Backend { let rope = rope.clone(); early_return.lift(|| async move { let mut items = MaxVec::new(self.completions_limit.load(Relaxed)); - self.complete_model(&needle, range.map_unit(ByteOffset), rope.clone(), &mut items) - .await?; + self.complete_model( + &needle, + range.shrink(1).map_unit(ByteOffset), + rope.clone(), + &mut items, + ) + .await?; Ok(Some(CompletionResponse::List(CompletionList { is_incomplete: !items.has_space(), items: items.into_inner(), @@ -401,7 +405,7 @@ impl Backend { return None; } - log::trace!( + tracing::trace!( "(gather_mapped) {} matches={match_:?}", String::from_utf8_lossy(&contents[range.clone()]) ); @@ -630,9 +634,7 @@ impl Backend { Some((lhs, field, range)) } pub fn python_references(&self, params: ReferenceParams, rope: Rope) -> miette::Result>> { - let Some(ByteOffset(offset)) = position_to_offset(params.text_document_position.position, &rope) else { - return Ok(None); - }; + let ByteOffset(offset) = some!(position_to_offset(params.text_document_position.position, &rope)); let uri = ¶ms.text_document_position.text_document.uri; let ast = self .ast_map @@ -643,9 +645,8 @@ impl Backend { let contents = Cow::from(rope.clone()); let contents = contents.as_bytes(); let mut cursor = tree_sitter::QueryCursor::new(); - let current_module = self - .index - .module_of_path(Path::new(params.text_document_position.text_document.uri.path())); + let path = some!(params.text_document_position.text_document.uri.to_file_path().ok()); + let current_module = self.index.module_of_path(&path); 'match_: for match_ in cursor.matches(query, root, contents) { for capture in match_.captures { let range = capture.node.byte_range(); @@ -842,6 +843,26 @@ impl Backend { } } Some(PyCompletions::Model) => { + match capture.node.parent() { + Some(subscript) if subscript.kind() == "subscript" => { + // diagnose only, do not tag + let range = capture.node.byte_range().shrink(1); + let model = String::from_utf8_lossy(&contents[range.clone()]); + let model_key = interner().get(&model); + let has_model = model_key.map(|model| self.index.models.contains_key(&model.into())); + if !has_model.unwrap_or(false) { + diagnostics.push(Diagnostic { + range: offset_range_to_lsp_range(range.map_unit(ByteOffset), rope.clone()) + .unwrap(), + message: format!("`{model}` is not a valid model name"), + severity: Some(DiagnosticSeverity::ERROR), + ..Default::default() + }) + } + continue; + } + _ => {} + } let Ok(idx) = top_level_ranges.binary_search_by(|range| { let needle = capture.node.end_byte(); if needle < range.start { @@ -933,7 +954,7 @@ impl Backend { if !has_field { diagnostics.push(Diagnostic { range: offset_range_to_lsp_range(range, rope.clone()).unwrap(), - severity: Some(DiagnosticSeverity::WARNING), + severity: Some(DiagnosticSeverity::ERROR), message: format!("Model `{}` has no field `{needle}`", interner().resolve(&model)), ..Default::default() }); @@ -966,6 +987,13 @@ impl Backend { if node.kind() != "attribute" || attribute.as_ref().unwrap().kind() != "identifier" { return ControlFlow::Continue(entered); } + match node.parent() { + Some(parent) if parent.kind() == "call" => { + // We don't handle methods yet, so don't diagnose them. + return ControlFlow::Continue(entered); + } + _ => {} + } let attribute = attribute.unwrap(); static MODEL_BUILTINS: phf::Set<&str> = @@ -993,7 +1021,7 @@ impl Backend { range: ts_range_to_lsp_range(attribute.range()), severity: Some(DiagnosticSeverity::ERROR), message: format!( - "Model `{}` has no field `{}", + "Model `{}` has no field `{}`", interner().resolve(&model_name), String::from_utf8_lossy(&contents[attribute.byte_range()]), ), diff --git a/src/template.rs b/src/template.rs index 225e59b..30d7c50 100644 --- a/src/template.rs +++ b/src/template.rs @@ -167,7 +167,7 @@ mod tests { Do nothing... -
+
Nested!
"#; @@ -183,16 +183,16 @@ mod tests { [ NewTemplate { base: true, .. }, NewTemplate { base: true, .. }, - NewTemplate { base: false, .. }, NewTemplate { base: true, .. }, + NewTemplate { base: false, .. }, ] ), "{templates:#?}" ); assert_eq!(interner().resolve(&templates[0].name), "first"); assert_eq!(interner().resolve(&templates[1].name), "second"); - assert_eq!(interner().resolve(&templates[2].name), "first"); - assert_eq!(interner().resolve(&templates[3].name), "primary_inherit"); + assert_eq!(interner().resolve(&templates[2].name), "doesnt_matter"); + assert_eq!(interner().resolve(&templates[3].name), "second"); } #[test] fn test_with_xml_decl() { diff --git a/src/utils.rs b/src/utils.rs index f8e3732..6351aaf 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,9 +1,9 @@ use core::ops::{Add, Sub}; use std::borrow::Cow; +use std::ffi::OsStr; use std::fmt::Display; -use std::fs::File; use std::future::Future; -use std::io::BufWriter; +use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use dashmap::try_result::TryResult; @@ -28,7 +28,7 @@ macro_rules! some { match $opt { Some(it) => it, None => { - log::trace!("{}", $crate::format_loc!("{}", concat!(stringify!($opt), " = None"))); + tracing::trace!("{}", $crate::format_loc!("{}", concat!(stringify!($opt), " = None"))); return Ok(None); } } @@ -362,7 +362,12 @@ impl TryResultExt for TryResult { #[cfg(test)] pub fn init_for_test() { - env_logger::builder().parse_filters("info,odoo_lsp=trace").init(); + use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter}; + + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer()) + .with(EnvFilter::from("info,odoo_lsp=trace")) + .init(); } #[derive(Default)] @@ -458,30 +463,6 @@ impl DisplayExt for std::fmt::Arguments<'_> { } } -pub struct FileTee { - file: Option>, -} - -impl FileTee { - pub fn new(file: Option) -> Self { - Self { - file: file.map(BufWriter::new), - } - } -} - -impl std::io::Write for FileTee { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - if let Some(file) = &mut self.file { - _ = file.write(buf); - } - std::io::stderr().write(buf) - } - fn flush(&mut self) -> std::io::Result<()> { - if let Some(file) = &mut self.file { - file.flush()?; - } - - Ok(()) - } +pub fn path_contains(path: impl AsRef, needle: impl AsRef) -> bool { + path.as_ref().components().any(|c| c.as_os_str() == needle.as_ref()) } diff --git a/src/utils/usage.rs b/src/utils/usage.rs index f72a455..cc2b7d1 100644 --- a/src/utils/usage.rs +++ b/src/utils/usage.rs @@ -19,6 +19,7 @@ use std::{ hash::Hash, marker::PhantomData, ops::Add, + path::PathBuf, sync::Arc, }; @@ -403,3 +404,9 @@ impl Usage for Arc { UsageInfo::new(self.as_ref().usage().0) } } + +impl Usage for PathBuf { + fn usage(&self) -> UsageInfo { + UsageInfo::new(self.capacity()) + } +} diff --git a/src/xml.rs b/src/xml.rs index 37c3d60..531648c 100644 --- a/src/xml.rs +++ b/src/xml.rs @@ -3,13 +3,11 @@ use crate::{Backend, Text}; use std::borrow::Cow; use std::cmp::Ordering; -use std::path::Path; use std::sync::atomic::Ordering::Relaxed; use std::sync::Arc; use fomat_macros::fomat; use lasso::Spur; -use log::{debug, warn}; use miette::diagnostic; use odoo_lsp::component::{ComponentTemplate, PropType}; use odoo_lsp::index::{interner, PathSymbol}; @@ -17,6 +15,7 @@ use odoo_lsp::model::{Field, FieldKind}; use odoo_lsp::template::gather_templates; use ropey::{Rope, RopeSlice}; use tower_lsp::lsp_types::*; +use tracing::{debug, warn}; use tree_sitter::Parser; use xmlparser::{ElementEnd, Error, StrSpan, StreamError, Token, Tokenizer}; @@ -68,16 +67,18 @@ impl Backend { let mut reader = Tokenizer::from(text.as_ref()); let mut record_ranges = vec![]; let interner = interner(); + let path = uri.to_file_path().map_err(|_| diagnostic!("uri.to_file_path failed"))?; let current_module = self .index - .module_of_path(Path::new(uri.path())) + .module_of_path(&path) .ok_or_else(|| diagnostic!("module_of_path for {} failed", uri.path()))?; let mut record_prefix = if did_save { Some(self.index.records.by_prefix.write().await) } else { None }; - let path_uri = PathSymbol::strip_root(root, Path::new(uri.path())); + let path = uri.to_file_path().unwrap(); + let path_uri = PathSymbol::strip_root(root, &path); loop { match reader.next() { Some(Ok(Token::ElementStart { local, span, .. })) => { @@ -204,11 +205,9 @@ impl Backend { let (slice, offset_at_cursor, relative_offset) = self.record_slice(&rope, uri, position)?; let slice_str = Cow::from(slice); let mut reader = Tokenizer::from(slice_str.as_ref()); + let path = some!(uri.to_file_path().ok()); - let current_module = self - .index - .module_of_path(Path::new(uri.path())) - .expect("must be in a module"); + let current_module = self.index.module_of_path(&path).expect("must be in a module"); let mut items = MaxVec::new(self.completions_limit.load(Relaxed)); let XmlRefs { @@ -407,6 +406,7 @@ impl Backend { pub fn xml_references(&self, params: ReferenceParams, rope: Rope) -> miette::Result>> { let position = params.text_document_position.position; let uri = ¶ms.text_document_position.text_document.uri; + let path = some!(uri.to_file_path().ok()); let (slice, cursor_by_char, _) = self.record_slice(&rope, uri, position)?; let slice_str = Cow::from(slice); let mut reader = Tokenizer::from(slice_str.as_ref()); @@ -416,12 +416,8 @@ impl Backend { .. } = self.gather_refs(cursor_by_char, &mut reader, &slice)?; - let Some((cursor_value, _)) = cursor_value else { - return Ok(None); - }; - let current_module = self - .index - .module_of_path(Path::new(params.text_document_position.text_document.uri.path())); + let (cursor_value, _) = some!(cursor_value); + let current_module = self.index.module_of_path(&path); match ref_kind { Some(RefKind::Model) => { let model = some!(interner().get(cursor_value)); @@ -440,6 +436,7 @@ impl Backend { pub fn xml_hover(&self, params: HoverParams, rope: Rope) -> miette::Result> { let position = params.text_document_position_params.position; let uri = ¶ms.text_document_position_params.text_document.uri; + let path = some!(uri.to_file_path().ok()); let (slice, offset_at_cursor, relative_offset) = self.record_slice(&rope, uri, position)?; let slice_str = Cow::from(slice); let mut reader = Tokenizer::from(slice_str.as_ref()); @@ -450,17 +447,13 @@ impl Backend { scope, } = self.gather_refs(offset_at_cursor, &mut reader, &slice)?; - let Some((mut needle, ref_range)) = ref_at_cursor else { - return Ok(None); - }; + let (mut needle, ref_range) = some!(ref_at_cursor); let mut lsp_range = offset_range_to_lsp_range( ref_range.clone().map_unit(|unit| ByteOffset(unit + relative_offset)), rope.clone(), ); - let current_module = self - .index - .module_of_path(Path::new(params.text_document_position_params.text_document.uri.path())); + let current_module = self.index.module_of_path(&path); match ref_kind { Some(RefKind::Model) => self.hover_model(needle, lsp_range, false, None), Some(RefKind::Ref(_)) => { diff --git a/testing/README.md b/testing/README.md new file mode 100644 index 0000000..bbebcf6 --- /dev/null +++ b/testing/README.md @@ -0,0 +1 @@ +End-to-end testing for odoo-lsp. diff --git a/testing/conftest.py b/testing/conftest.py new file mode 100644 index 0000000..b0feba1 --- /dev/null +++ b/testing/conftest.py @@ -0,0 +1,62 @@ +# pyright: strict + +from lsprotocol.types import ( + ClientCapabilities, + CompletionClientCapabilities, + InitializeParams, + PublishDiagnosticsClientCapabilities, + TextDocumentClientCapabilities, + WindowClientCapabilities, + WorkspaceFolder, +) + +import pytest +import pytest_lsp +import os +import subprocess +from pathlib import Path +from pytest_lsp import ClientServerConfig +from pytest_lsp import LanguageClient +from shutil import which + + +__dirname = os.path.dirname(os.path.realpath(__file__)) + +if lsp_devtools := which("lsp-devtools"): + odoocmd = [lsp_devtools, "agent", "--", f"{__dirname}/../target/debug/odoo-lsp"] +else: + odoocmd = [f"{__dirname}/../target/debug/odoo-lsp"] +ODOO_ENV = {"RUST_LOG": "info,odoo_lsp=trace"} + + +@pytest.fixture +def rootdir(): + return __dirname + + +@pytest.fixture(autouse=True, scope="session") +def setup(): + subprocess.run(["cargo", "build"], env=dict(os.environ, CARGO_TERM_COLOR="always")) + + +assert os.getcwd().startswith(__dirname), "Tests must be executed from within /testing" + + +@pytest_lsp.fixture( + scope="module", + config=ClientServerConfig(server_command=odoocmd, server_env=ODOO_ENV), +) +async def client(lsp_client: LanguageClient, rootdir: str): + params = InitializeParams( + workspace_folders=[WorkspaceFolder(uri=Path(rootdir).as_uri(), name="odoo-lsp")], + capabilities=ClientCapabilities( + window=WindowClientCapabilities(work_done_progress=True), + text_document=TextDocumentClientCapabilities( + publish_diagnostics=PublishDiagnosticsClientCapabilities(), + completion=CompletionClientCapabilities(), + ), + ), + ) + await lsp_client.initialize_session(params) + yield + await lsp_client.shutdown_session() diff --git a/testing/fixtures/basic/.odoo_lsp b/testing/fixtures/basic/.odoo_lsp new file mode 100644 index 0000000..74626e0 --- /dev/null +++ b/testing/fixtures/basic/.odoo_lsp @@ -0,0 +1,16 @@ +{ + "module": { + "roots": [ + "." + ] + }, + "symbols": { + "limit": 80 + }, + "references": { + "limit": 80 + }, + "completions": { + "limit": 200 + } +} diff --git a/testing/fixtures/basic/bar/__manifest__.py b/testing/fixtures/basic/bar/__manifest__.py new file mode 100644 index 0000000..18bb504 --- /dev/null +++ b/testing/fixtures/basic/bar/__manifest__.py @@ -0,0 +1 @@ +{"name": "bar"} diff --git a/testing/fixtures/basic/bar/models.py b/testing/fixtures/basic/bar/models.py new file mode 100644 index 0000000..de7f365 --- /dev/null +++ b/testing/fixtures/basic/bar/models.py @@ -0,0 +1,10 @@ +class Bar(models.Model): + _name = "bar" + + +class DerivedBar(models.Model): + _name = "derived.bar" + _inherit = "bar" + + def test(self): + self.env["bar"] diff --git a/testing/fixtures/basic/conftest.py b/testing/fixtures/basic/conftest.py new file mode 100644 index 0000000..a37edc0 --- /dev/null +++ b/testing/fixtures/basic/conftest.py @@ -0,0 +1,7 @@ +import os +import pytest + + +@pytest.fixture(scope="module") +def rootdir(): + return os.path.dirname(os.path.realpath(__file__)) diff --git a/testing/fixtures/basic/foo/__manifest__.py b/testing/fixtures/basic/foo/__manifest__.py new file mode 100644 index 0000000..7c4c2f5 --- /dev/null +++ b/testing/fixtures/basic/foo/__manifest__.py @@ -0,0 +1 @@ +{"name": "foo"} diff --git a/testing/fixtures/basic/foo/models.py b/testing/fixtures/basic/foo/models.py new file mode 100644 index 0000000..9ed12be --- /dev/null +++ b/testing/fixtures/basic/foo/models.py @@ -0,0 +1,15 @@ +class Foo(Model): + _name = "foo" + + def test(self): + self.env["bar"] + + def diagnostics(self): + self.foo + self.env["foo"].foo + self.mapped("foo") + self.env["fo"] + + +class Foob(Model): + _name = "foob" diff --git a/testing/fixtures/basic/test_main.py b/testing/fixtures/basic/test_main.py new file mode 100644 index 0000000..d7ebc5e --- /dev/null +++ b/testing/fixtures/basic/test_main.py @@ -0,0 +1,75 @@ +import pytest +from pathlib import Path +from pytest_lsp import LanguageClient +from lsprotocol.types import ( + DidOpenTextDocumentParams, + TextDocumentItem, + CompletionParams, + TextDocumentIdentifier, + Position, + CompletionList, + Diagnostic, +) + + +@pytest.mark.asyncio(scope="module") +async def test_completions(client: LanguageClient, rootdir: str): + """Test various basic completions.""" + + client.text_document_did_open( + DidOpenTextDocumentParams( + TextDocumentItem( + uri=f"file://{rootdir}/foo/models.py", + language_id="python", + version=1, + text=Path(f"{rootdir}/foo/models.py").read_text(), + ) + ) + ) + await client.wait_for_notification("textDocument/publishDiagnostics") + + results = await client.text_document_completion_async( + CompletionParams( + TextDocumentIdentifier(uri=f"file://{rootdir}/foo/models.py"), + Position(4, 18), + ) + ) + if isinstance(results, CompletionList): + results = results.items + assert results + assert [e.label for e in results] == ["bar", "derived.bar", "foo", "foob"] + + +def splay_diag(diags: list[Diagnostic]): + return ( + ( + d.range.start.line, + d.range.start.character, + d.range.end.line, + d.range.end.character, + d.message, + ) + for d in diags + ) + + +@pytest.mark.asyncio(scope="module") +async def test_diagnostics(client: LanguageClient, rootdir: str): + client.text_document_did_open( + DidOpenTextDocumentParams( + TextDocumentItem( + uri=f"file://{rootdir}/foo/models.py", + language_id="python", + version=1, + text=Path(f"{rootdir}/foo/models.py").read_text(), + ) + ) + ) + await client.wait_for_notification("textDocument/publishDiagnostics") + diagnostics = client.diagnostics[Path(f"{rootdir}/foo/models.py").as_uri()] + assert list(splay_diag(diagnostics)) == [ + (7, 13, 7, 16, "Model `foo` has no field `foo`"), + (8, 24, 8, 27, "Model `foo` has no field `foo`"), + (9, 21, 9, 24, "Model `foo` has no field `foo`"), + (10, 18, 10, 20, "`fo` is not a valid model name"), + ] diff --git a/testing/pyproject.toml b/testing/pyproject.toml new file mode 100644 index 0000000..9bb9684 --- /dev/null +++ b/testing/pyproject.toml @@ -0,0 +1,3 @@ +[tool.pytest.ini_options] +timeout = 30 +session_timeout = 600 \ No newline at end of file diff --git a/testing/requirements.txt b/testing/requirements.txt new file mode 100644 index 0000000..f7bc029 --- /dev/null +++ b/testing/requirements.txt @@ -0,0 +1,14 @@ +attrs==23.2.0 +cattrs==23.2.3 +exceptiongroup==1.2.1 +iniconfig==2.0.0 +lsprotocol==2023.0.1 +packaging==24.0 +pluggy==1.5.0 +pygls==1.3.1 +pytest==8.2.0 +pytest-asyncio==0.23.6 +pytest-lsp==0.4.1 +pytest-timeout==2.3.1 +tomli==2.0.1 +typing-extensions==4.11.0 diff --git a/testing/scaffold.py b/testing/scaffold.py new file mode 100755 index 0000000..355a78f --- /dev/null +++ b/testing/scaffold.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# pyright: strict +import sys +import os +from pathlib import Path +from shutil import copyfile + +assert __name__ == "__main__", "Not supported as a library." + + +def die(msg: str): + print(msg, file=sys.stderr) + exit(1) + + +args = list(sys.argv) +if args and args[0] == "./scaffold.py": + args.pop(0) + +if len(args) < 1: + die("Usage: scaffold.py fixtures/fixture_dir [addon1 addon2 ..]") + +fixture_dir = Path(args.pop(0)) +os.makedirs(fixture_dir, exist_ok=True) +copyfile("template.py", fixture_dir.joinpath("test_main.py")) +with open(fixture_dir.joinpath(".odoo_lsp"), "w+") as config: + config.write('{"module":{"roots":["."]}}') +for addon in args: + os.mkdir(fixture_dir.joinpath(addon)) + with open(fixture_dir.joinpath(addon, "__manifest__.py"), "w+") as manifest: + manifest.write('{"name": "%s"}' % addon) diff --git a/testing/template.py b/testing/template.py new file mode 100644 index 0000000..3a329b1 --- /dev/null +++ b/testing/template.py @@ -0,0 +1,10 @@ +import pytest +from pytest_lsp import LanguageClient + +# from lsprotocol.types import () + + +@pytest.mark.asyncio +async def test_sanity(client: LanguageClient): + """Ensure that the server implements completions correctly.""" + pass