diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1f6c51d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,128 @@ +name: release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +env: + BIN_NAME: objectscript-lsp + +jobs: + build-and-upload: + strategy: + fail-fast: false + matrix: + include: + # Linux (glibc) + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + platform: linux-x64 + build: cargo + + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + platform: linux-arm64 + build: cross + + # Linux (musl / Alpine) + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + platform: alpine-x64 + build: cross + + - os: ubuntu-latest + target: aarch64-unknown-linux-musl + platform: alpine-arm64 + build: cross + + # macOS + - os: macos-latest + target: x86_64-apple-darwin + platform: darwin-x64 + build: cargo + + - os: macos-latest + target: aarch64-apple-darwin + platform: darwin-arm64 + build: cargo + + # Windows + - os: windows-latest + target: x86_64-pc-windows-msvc + platform: win32-x64 + build: cargo + + - os: windows-latest + target: aarch64-pc-windows-msvc + platform: win32-arm64 + build: cargo + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + workspaces: objectscript-lsp -> target + + - name: Build (native) + if: matrix.build == 'cargo' + working-directory: objectscript-lsp + run: cargo build --release --target ${{ matrix.target }} + + - name: Install cross + if: matrix.build == 'cross' + run: cargo install cross + + - name: Build (cross) + if: matrix.build == 'cross' + working-directory: objectscript-lsp + run: cross build --release --target ${{ matrix.target }} + + # Package for macOS/Linux + - name: Package (tar.gz) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + OUT="${{ env.BIN_NAME }}-${TAG}-${{ matrix.platform }}" + BIN="${{ env.BIN_NAME }}" + + mkdir -p "${OUT}/bin" + cp "objectscript-lsp/target/${{ matrix.target }}/release/${BIN}" "${OUT}/bin/" + chmod +x "${OUT}/bin/${BIN}" + + tar -czf "${OUT}.tar.gz" -C "${OUT}" . + echo "ASSET=${OUT}.tar.gz" >> "$GITHUB_ENV" + + # Package for Windows + - name: Package (zip) + if: runner.os == 'Windows' + shell: pwsh + run: | + $tag = "${{ github.ref_name }}" + $out = "${{ env.BIN_NAME }}-$tag-${{ matrix.platform }}" + $bin = "${{ env.BIN_NAME }}.exe" + + New-Item -ItemType Directory -Force "$out/bin" | Out-Null + Copy-Item "objectscript-lsp\target\${{ matrix.target }}\release\$bin" "$out\bin\" + Compress-Archive -Path "$out\*" -DestinationPath "$out.zip" + "ASSET=$out.zip" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Upload asset to GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: ${{ env.ASSET }} diff --git a/.gitignore b/.gitignore index a2e4eb7..a1f5a1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,13 @@ /*.wasm -/target +target/ grammars/ - +**/*.rs.bk +Cargo.lock +# Optional: common local/IDE files +.DS_Store +.vscode/ +.idea/ +.tabnine/ +vscode/ +objectscript-lsp/objectscript-tests/local/ +objectscript-lsp/documentation/ \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e22a04a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "zed-objectscript" +version = "1.5.0" +edition = "2021" +license = "MIT" + +[lib] +path = "src/objectscript.rs" +crate-type = ["cdylib"] + +[dependencies] +zed_extension_api = "0.7.0" diff --git a/extension.toml b/extension.toml index 812e1eb..f0b0034 100644 --- a/extension.toml +++ b/extension.toml @@ -1,6 +1,6 @@ id = "objectscript" name = "InterSystems ObjectScript" -version = "1.4.0" +version = "1.5.0" schema_version = 1 authors = ["Dave McCaldon ","Hannah Kimura "] description = "InterSystems IRIS ObjectScript Extension" @@ -8,20 +8,24 @@ repository = "https://github.com/intersystems/zed-objectscript" [grammars.objectscript_udl] repository = "https://github.com/intersystems/tree-sitter-objectscript" -commit = "ce29cca45e01ff3be4894204f681b0aecf9409a5" +commit = "d29750b076de8e9fce475225041ae008e357bf2c" path = "udl" [grammars.objectscript_routine] repository = "https://github.com/intersystems/tree-sitter-objectscript" -commit = "ce29cca45e01ff3be4894204f681b0aecf9409a5" +commit = "d29750b076de8e9fce475225041ae008e357bf2c" path = "objectscript_routine" [grammars.objectscript] repository = "https://github.com/intersystems/tree-sitter-objectscript" -commit = "ce29cca45e01ff3be4894204f681b0aecf9409a5" +commit = "d29750b076de8e9fce475225041ae008e357bf2c" path = "objectscript" [grammars.xml] repository = "https://github.com/tree-sitter-grammars/tree-sitter-xml" commit = "5000ae8f22d11fbe93939b05c1e37cf21117162d" path = "xml" + +[language_servers.objectscript-lsp] +name = "objectscript-lsp" +languages = ["objectscript_udl", "objectscript_routine", "xml", "objectscript"] diff --git a/languages/objectscript/highlights.scm b/languages/objectscript/highlights.scm index 6f11b94..0564fd5 100644 --- a/languages/objectscript/highlights.scm +++ b/languages/objectscript/highlights.scm @@ -4,10 +4,7 @@ ; === BEGIN EXPR === (pattern_expression) @string.regex -[ - (json_number_literal) - (numeric_literal) -] @number +(numeric_literal) @number [ (json_boolean_literal) @@ -23,12 +20,17 @@ ] @string [ - (keyword_pound_pound_super) + (keyword_super) (keyword_pound_pound_class) ] @keyword (system_defined_function) @constant.builtin +[ + "(" + ")" +] @constant.builtin + ; this is because . is grouped into system_defined_function ; and I want the dots to be the same color (class_method_call @@ -89,7 +91,6 @@ "']" "']]" "\"" - "\"\"" "[" "]" "]]" @@ -129,8 +130,6 @@ "?" ] @operator -(bracket) @punctuation.bracket - ; === END EXPR === ; === BEGIN CORE === (macro_arg) @variant @@ -139,11 +138,6 @@ (macro_def) @preproc -[ - "(" - ")" -] @punctuation.bracket - [ (keyword_pound_define) (keyword_pound_def1arg) @@ -289,13 +283,9 @@ (elseif_block_dotted "." @string.special.symbol) -(catch_block_dotted - "." @string.special.symbol) -(variable_datatype - "." @constant.builtin) -(instance_method_call +(variable_datatype "." @constant.builtin) ; === END CORE === @@ -330,7 +320,7 @@ (class_keywords) (query_keywords) (trigger_keyword) - (method_keyword_language) + (method_keyword_external_language) (relationship_keyword) (foreignkey_keyword) (parameter_keyword) @@ -359,6 +349,7 @@ (storage_name) (xml_identifier) (index_property) + (column_name) ] @variant [ diff --git a/languages/objectscript/injections.scm b/languages/objectscript/injections.scm index 5e48fee..161ca8e 100644 --- a/languages/objectscript/injections.scm +++ b/languages/objectscript/injections.scm @@ -1,3 +1,7 @@ +; AUTO-GENERATED by scripts/sync_queries.py +; Edit only the LOCAL section in composed files. +; File: injections.scm +; === BEGIN CORE === (embedded_html (angled_bracket_fenced_text) @injection.content (#set! injection.language "html")) @@ -18,63 +22,48 @@ (angled_bracket_fenced_text) @injection.content (#set! injection.language "xml")) -([ - (line_comment_1) - (line_comment_2) - (line_comment_3) - (block_comment) -] @injection.content - (#set! injection.language "comment")) - +; === END CORE === +; === BEGIN LOCAL === ; Keywords, one of type language = "python", none of type codemode ; External method body injection based on [ Language = ... ] (method_definition - (external_method_keywords - (method_keyword_language - (typename) @_lang)) + (method_keyword_external_language + (typename) @_lang) (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_lang "^[Pp][Yy][Tt][Hh][Oo][Nn]$") + (#any-of? @_lang "python" "Python" "PYTHON") (#set! injection.language "python")) (method_definition - (external_method_keywords - (method_keyword_language - (typename) @_lang)) + (method_keyword_external_language + (typename) @_lang) (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_lang "^[Tt][Ss][Qq][Ll]$") + (#any-of? @_lang "tsql" "TSQL" "tSQL" "tSql" "TSql") (#set! injection.language "tsql")) (method_definition - (external_method_keywords - (method_keyword_language - (typename) @_lang)) + (method_keyword_external_language + (typename) @_lang) (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_lang "^[Ii][Ss][Pp][Ll]$") + (#any-of? @_lang "ispl" "ISPL" "ISpl" "iSpl") (#set! injection.language "ispl")) -; External trigger with python body -((trigger - (external_trigger - (trigger_keywords - (method_keyword_language - (typename) @_lang)) - (external_method_body_content) @injection.content)) +(trigger + (method_keyword_external_language + (typename) @_lang) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_lang "^[Pp][Yy][Tt][Hh][Oo][Nn]$") + (#any-of? @_lang "python" "Python" "PYTHON") (#set! injection.language "python")) -; External trigger with TSQL body -((trigger - (external_trigger - (trigger_keywords - (method_keyword_language - (typename) @_lang)) - (external_method_body_content) @injection.content)) +(trigger + (method_keyword_external_language + (typename) @_lang) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_lang "^[Tt][Ss][Qq][Ll]$") + (#any-of? @_lang "tsql" "TSQL" "tSQL" "tSql" "TSql") (#set! injection.language "tsql")) ; A query must be of type %SQLQuery to have an SQL body, otherwise the body @@ -83,9 +72,8 @@ (return_type (typename (identifier) @_querytype - (#match? @_querytype "^%[Ss][Qq][Ll][Qq][Uu][Ee][Rr][Yy]$"))) - (query_body - (query_body_content) @injection.content) + (#any-of? @_querytype "%SqlQuery" "%sqlquery" "%SQLQUERY" "%Sqlquery" "%SQLQuery"))) + (external_method_body_content) @injection.content (#set! injection.language "sql") (#set! injection.include-children "true")) @@ -96,86 +84,67 @@ ; XDATA injections (MimeType) ; ---------------------------- ; text/markdown +; (xdata - (xdata_any - (xdata_keywords - (xdata_keyword_mimetype - (typename) @_mt)) - (external_method_body_content) @injection.content) + (xdata_keyword_mimetype + (typename) @_mt) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_mt "^\"?text/markdown\"?$") + (#any-of? @_mt "text/markdown" "\"text/markdown\"") (#set! injection.language "markdown")) -; XML MimeTypes (xdata - (xdata_any - (xdata_keywords - (xdata_keyword_mimetype - (typename) @_mt)) - (external_method_body_content) @injection.content) + (xdata_keyword_mimetype + (typename) @_mt) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_mt - "^\"?([Tt][Ee][Xx][Tt]|[Aa][Pp][Pp][Ll][Ii][Cc][Aa][Tt][Ii][Oo][Nn])/[Xx][Mm][Ll]\"?$") + (#any-of? @_mt "text/xml" "\"text/xml\"" "application/xml" "\"application/xml\"") (#set! injection.language "xml")) -; text/html (xdata - (xdata_any - (xdata_keywords - (xdata_keyword_mimetype - (typename) @_mt)) - (external_method_body_content) @injection.content) + (xdata_keyword_mimetype + (typename) @_mt) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_mt "^\"?text/html\"?$") + (#any-of? @_mt "text/html" "\"text/html\"") (#set! injection.language "html")) -; application/json (xdata - (xdata_any - (xdata_keywords - (xdata_keyword_mimetype - (typename) @_mt)) - (external_method_body_content) @injection.content) + (xdata_keyword_mimetype + (typename) @_mt) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_mt "^\"?application/json\"?$") + (#any-of? @_mt "application/json" "\"application/json\"") (#set! injection.language "json")) -; text/yaml or application/yaml (xdata - (xdata_any - (xdata_keywords - (xdata_keyword_mimetype - (typename) @_mt)) - (external_method_body_content) @injection.content) + (xdata_keyword_mimetype + (typename) @_mt) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_mt - "^\"?([Tt][Ee][Xx][Tt]|[Aa][Pp][Pp][Ll][Ii][Cc][Aa][Tt][Ii][Oo][Nn])/[Yy][Aa][Mm][Ll]\"?$") + (#any-of? @_mt "text/yaml" "\"text/yaml\"" "application/yaml" "\"application/yaml\"") (#set! injection.language "yaml")) -; text/css (xdata - (xdata_any - (xdata_keywords - (xdata_keyword_mimetype - (typename) @_mt)) - (external_method_body_content) @injection.content) + (xdata_keyword_mimetype + (typename) @_mt) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_mt "^\"?text/css\"?$") + (#any-of? @_mt "text/css" "\"text/css\"") (#set! injection.language "css")) ; ----------------------------------------- ; XDATA default (no MimeType): XML fallback ; ----------------------------------------- (xdata - (xdata_xml - (xdata_keywords)? - (external_method_body_content) @injection.content) + (external_method_body_content) @injection.content (#set! injection.include-children "true") (#set! injection.language "xml")) ; Storage definition is XML (storage - (storage_body - (external_method_body_content) @injection.content) + (external_method_body_content) @injection.content (#set! injection.language "xml") (#set! injection.include-children "true")) + +; === END LOCAL === diff --git a/languages/objectscript_routine/highlights.scm b/languages/objectscript_routine/highlights.scm index 55b10ef..3efe7b9 100644 --- a/languages/objectscript_routine/highlights.scm +++ b/languages/objectscript_routine/highlights.scm @@ -4,10 +4,7 @@ ; === BEGIN EXPR === (pattern_expression) @string.regex -[ - (json_number_literal) - (numeric_literal) -] @number +(numeric_literal) @number [ (json_boolean_literal) @@ -23,12 +20,17 @@ ] @string [ - (keyword_pound_pound_super) + (keyword_super) (keyword_pound_pound_class) ] @keyword (system_defined_function) @constant.builtin +[ + "(" + ")" +] @constant.builtin + ; this is because . is grouped into system_defined_function ; and I want the dots to be the same color (class_method_call @@ -89,7 +91,6 @@ "']" "']]" "\"" - "\"\"" "[" "]" "]]" @@ -129,8 +130,6 @@ "?" ] @operator -(bracket) @punctuation.bracket - ; === END EXPR === ; === BEGIN CORE === (macro_arg) @variant @@ -139,11 +138,6 @@ (macro_def) @preproc -[ - "(" - ")" -] @punctuation.bracket - [ (keyword_pound_define) (keyword_pound_def1arg) @@ -289,15 +283,9 @@ (elseif_block_dotted "." @string.special.symbol) -(catch_block_dotted - "." @string.special.symbol) - (variable_datatype "." @constant.builtin) -(instance_method_call - "." @constant.builtin) - (routine_type) @type.builtin (documatic_line) @comment.doc diff --git a/languages/objectscript_routine/injections.scm b/languages/objectscript_routine/injections.scm index c6bfab6..674713d 100644 --- a/languages/objectscript_routine/injections.scm +++ b/languages/objectscript_routine/injections.scm @@ -17,11 +17,3 @@ (embedded_xml (angled_bracket_fenced_text) @injection.content (#set! injection.language "xml")) - -([ - (line_comment_1) - (line_comment_2) - (line_comment_3) - (block_comment) -] @injection.content - (#set! injection.language "comment")) diff --git a/languages/objectscript_udl/config.toml b/languages/objectscript_udl/config.toml index a8d9f7e..50d1842 100644 --- a/languages/objectscript_udl/config.toml +++ b/languages/objectscript_udl/config.toml @@ -1,4 +1,4 @@ -name = "ObjectscriptUdl" +name = "objectscript_udl" grammar = "objectscript_udl" path_suffixes = ["cls"] line_comments = ["// ", ";", ";;", "#;"] diff --git a/languages/objectscript_udl/highlights.scm b/languages/objectscript_udl/highlights.scm index 70e705b..3ddb6b4 100644 --- a/languages/objectscript_udl/highlights.scm +++ b/languages/objectscript_udl/highlights.scm @@ -1,13 +1,6 @@ -; AUTO-GENERATED by scripts/sync_queries.py -; Edit only the LOCAL section in composed files. -; File: highlights.scm -; === BEGIN EXPR === (pattern_expression) @string.regex -[ - (json_number_literal) - (numeric_literal) -] @number +(numeric_literal) @number [ (json_boolean_literal) @@ -23,12 +16,19 @@ ] @string [ - (keyword_pound_pound_super) + (keyword_super) (keyword_pound_pound_class) ] @keyword (system_defined_function) @constant.builtin + + +[ + "(" + ")" +] @constant.builtin + ; this is because . is grouped into system_defined_function ; and I want the dots to be the same color (class_method_call @@ -53,7 +53,7 @@ [ (routine_name) (class_name) -] @type +] @enum [ (macro_function) @@ -89,7 +89,6 @@ "']" "']]" "\"" - "\"\"" "[" "]" "]]" @@ -129,8 +128,6 @@ "?" ] @operator -(bracket) @punctuation.bracket - ; === END EXPR === ; === BEGIN CORE === (macro_arg) @variant @@ -139,11 +136,6 @@ (macro_def) @preproc -[ - "(" - ")" -] @punctuation.bracket - [ (keyword_pound_define) (keyword_pound_def1arg) @@ -231,7 +223,7 @@ (keyword_throw) (keyword_try) (keyword_catch) -] @type.builtin +] @keyword [ (keyword_embedded_html) @@ -289,15 +281,9 @@ (elseif_block_dotted "." @string.special.symbol) -(catch_block_dotted - "." @string.special.symbol) - (variable_datatype "." @constant.builtin) -(instance_method_call - "." @constant.builtin) - ; === END CORE === ; === BEGIN LOCAL === (iris_username) @preproc @@ -320,17 +306,21 @@ (keyword_trigger) (keyword_xdata) (keyword_storage) -] @attribute + (keyword_not) + (keyword_references) + (keyword_byref) + (keyword_output) +] @keyword [ (method_keyword_codemode_expression) (call_method_keyword) (extent_index_keyword) (method_keyword) - (class_keywords) - (query_keywords) + (class_keyword) + (query_keyword) (trigger_keyword) - (method_keyword_language) + (method_keyword_external_language) (relationship_keyword) (foreignkey_keyword) (parameter_keyword) @@ -339,11 +329,7 @@ (xdata_keyword) (xdata_keyword_mimetype) (property_keyword) - (keyword_not) - (keyword_references) - (keyword_byref) - (keyword_output) -] @type.builtin +] @attribute (documatic_line) @comment.doc @@ -359,6 +345,7 @@ (storage_name) (xml_identifier) (index_property) + (column_name) ] @variant [ diff --git a/languages/objectscript_udl/injections.scm b/languages/objectscript_udl/injections.scm index 5e48fee..161ca8e 100644 --- a/languages/objectscript_udl/injections.scm +++ b/languages/objectscript_udl/injections.scm @@ -1,3 +1,7 @@ +; AUTO-GENERATED by scripts/sync_queries.py +; Edit only the LOCAL section in composed files. +; File: injections.scm +; === BEGIN CORE === (embedded_html (angled_bracket_fenced_text) @injection.content (#set! injection.language "html")) @@ -18,63 +22,48 @@ (angled_bracket_fenced_text) @injection.content (#set! injection.language "xml")) -([ - (line_comment_1) - (line_comment_2) - (line_comment_3) - (block_comment) -] @injection.content - (#set! injection.language "comment")) - +; === END CORE === +; === BEGIN LOCAL === ; Keywords, one of type language = "python", none of type codemode ; External method body injection based on [ Language = ... ] (method_definition - (external_method_keywords - (method_keyword_language - (typename) @_lang)) + (method_keyword_external_language + (typename) @_lang) (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_lang "^[Pp][Yy][Tt][Hh][Oo][Nn]$") + (#any-of? @_lang "python" "Python" "PYTHON") (#set! injection.language "python")) (method_definition - (external_method_keywords - (method_keyword_language - (typename) @_lang)) + (method_keyword_external_language + (typename) @_lang) (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_lang "^[Tt][Ss][Qq][Ll]$") + (#any-of? @_lang "tsql" "TSQL" "tSQL" "tSql" "TSql") (#set! injection.language "tsql")) (method_definition - (external_method_keywords - (method_keyword_language - (typename) @_lang)) + (method_keyword_external_language + (typename) @_lang) (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_lang "^[Ii][Ss][Pp][Ll]$") + (#any-of? @_lang "ispl" "ISPL" "ISpl" "iSpl") (#set! injection.language "ispl")) -; External trigger with python body -((trigger - (external_trigger - (trigger_keywords - (method_keyword_language - (typename) @_lang)) - (external_method_body_content) @injection.content)) +(trigger + (method_keyword_external_language + (typename) @_lang) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_lang "^[Pp][Yy][Tt][Hh][Oo][Nn]$") + (#any-of? @_lang "python" "Python" "PYTHON") (#set! injection.language "python")) -; External trigger with TSQL body -((trigger - (external_trigger - (trigger_keywords - (method_keyword_language - (typename) @_lang)) - (external_method_body_content) @injection.content)) +(trigger + (method_keyword_external_language + (typename) @_lang) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_lang "^[Tt][Ss][Qq][Ll]$") + (#any-of? @_lang "tsql" "TSQL" "tSQL" "tSql" "TSql") (#set! injection.language "tsql")) ; A query must be of type %SQLQuery to have an SQL body, otherwise the body @@ -83,9 +72,8 @@ (return_type (typename (identifier) @_querytype - (#match? @_querytype "^%[Ss][Qq][Ll][Qq][Uu][Ee][Rr][Yy]$"))) - (query_body - (query_body_content) @injection.content) + (#any-of? @_querytype "%SqlQuery" "%sqlquery" "%SQLQUERY" "%Sqlquery" "%SQLQuery"))) + (external_method_body_content) @injection.content (#set! injection.language "sql") (#set! injection.include-children "true")) @@ -96,86 +84,67 @@ ; XDATA injections (MimeType) ; ---------------------------- ; text/markdown +; (xdata - (xdata_any - (xdata_keywords - (xdata_keyword_mimetype - (typename) @_mt)) - (external_method_body_content) @injection.content) + (xdata_keyword_mimetype + (typename) @_mt) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_mt "^\"?text/markdown\"?$") + (#any-of? @_mt "text/markdown" "\"text/markdown\"") (#set! injection.language "markdown")) -; XML MimeTypes (xdata - (xdata_any - (xdata_keywords - (xdata_keyword_mimetype - (typename) @_mt)) - (external_method_body_content) @injection.content) + (xdata_keyword_mimetype + (typename) @_mt) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_mt - "^\"?([Tt][Ee][Xx][Tt]|[Aa][Pp][Pp][Ll][Ii][Cc][Aa][Tt][Ii][Oo][Nn])/[Xx][Mm][Ll]\"?$") + (#any-of? @_mt "text/xml" "\"text/xml\"" "application/xml" "\"application/xml\"") (#set! injection.language "xml")) -; text/html (xdata - (xdata_any - (xdata_keywords - (xdata_keyword_mimetype - (typename) @_mt)) - (external_method_body_content) @injection.content) + (xdata_keyword_mimetype + (typename) @_mt) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_mt "^\"?text/html\"?$") + (#any-of? @_mt "text/html" "\"text/html\"") (#set! injection.language "html")) -; application/json (xdata - (xdata_any - (xdata_keywords - (xdata_keyword_mimetype - (typename) @_mt)) - (external_method_body_content) @injection.content) + (xdata_keyword_mimetype + (typename) @_mt) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_mt "^\"?application/json\"?$") + (#any-of? @_mt "application/json" "\"application/json\"") (#set! injection.language "json")) -; text/yaml or application/yaml (xdata - (xdata_any - (xdata_keywords - (xdata_keyword_mimetype - (typename) @_mt)) - (external_method_body_content) @injection.content) + (xdata_keyword_mimetype + (typename) @_mt) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_mt - "^\"?([Tt][Ee][Xx][Tt]|[Aa][Pp][Pp][Ll][Ii][Cc][Aa][Tt][Ii][Oo][Nn])/[Yy][Aa][Mm][Ll]\"?$") + (#any-of? @_mt "text/yaml" "\"text/yaml\"" "application/yaml" "\"application/yaml\"") (#set! injection.language "yaml")) -; text/css (xdata - (xdata_any - (xdata_keywords - (xdata_keyword_mimetype - (typename) @_mt)) - (external_method_body_content) @injection.content) + (xdata_keyword_mimetype + (typename) @_mt) + (external_method_body_content) @injection.content (#set! injection.include-children "true") - (#match? @_mt "^\"?text/css\"?$") + (#any-of? @_mt "text/css" "\"text/css\"") (#set! injection.language "css")) ; ----------------------------------------- ; XDATA default (no MimeType): XML fallback ; ----------------------------------------- (xdata - (xdata_xml - (xdata_keywords)? - (external_method_body_content) @injection.content) + (external_method_body_content) @injection.content (#set! injection.include-children "true") (#set! injection.language "xml")) ; Storage definition is XML (storage - (storage_body - (external_method_body_content) @injection.content) + (external_method_body_content) @injection.content (#set! injection.language "xml") (#set! injection.include-children "true")) + +; === END LOCAL === diff --git a/languages/xml/config.toml b/languages/xml/config.toml index 7f8c7d2..27a9986 100644 --- a/languages/xml/config.toml +++ b/languages/xml/config.toml @@ -1,4 +1,4 @@ -name = "ObjectScript XML" +name = "xml" grammar = "xml" path_suffixes = ["xml"] first_line_pattern = '^<.*xml' diff --git a/objectscript-lsp/Cargo.toml b/objectscript-lsp/Cargo.toml new file mode 100644 index 0000000..9681802 --- /dev/null +++ b/objectscript-lsp/Cargo.toml @@ -0,0 +1,22 @@ +[workspace] +members = [".", "crates/objectscript-core"] + +[package] +name = "objectscript-lsp" +version = "0.1.1" +edition = "2024" +license = "MIT" +description = "Language Server Protocol for ObjectScript" + +[dependencies] +objectscript-core = { path = "crates/objectscript-core" } +tower-lsp = "0.20.0" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "fs"] } +tree-sitter = "0.26.6" +tree-sitter-objectscript = "1.9.12" +tree-sitter-objectscript-routine = "1.9.12" +tree-sitter-objectscript-playground = "1.9.12" +serde = "1.0.228" +serde_json = "1.0.145" +parking_lot = "0.12.5" +walkdir = "2.5.0" diff --git a/objectscript-lsp/README.md b/objectscript-lsp/README.md new file mode 100644 index 0000000..bcf5f3c --- /dev/null +++ b/objectscript-lsp/README.md @@ -0,0 +1,85 @@ +# ObjectScript LSP + +Language Server Protocol implementation for InterSystems ObjectScript using `tower-lsp` and `tree-sitter`. + +Goal: provide editor-independent ObjectScript semantics for VS Code, Zed, Neovim, and other LSP clients without requiring a live InterSystems server connection. + +## Current Features + +- Workspace indexing for `.cls`, `.inc`, `.rtn`, `.mac`, and `.int` +- Multi-workspace support through LSP workspace folders, with deepest-parent routing per document +- Go-to-definition for ObjectScript variables with ProcedureBlock-aware private/public resolution +- Go-to-implementation for inherited and overridden methods and classes +- Syntax diagnostics for tracked ObjectScript documents +- Mixed-language diagnostics for ObjectScript captured from XML `Implementation` blocks +- Refactor code actions for: + - Legacy dotted `DO` rewrites + - Legacy `IF/Else` rewrites + - Legacy `FOR` rewrites + - document-scoped and workspace-scoped edits +- Inheritance modeling and override index build + + +## Architecture Summary + +- One `ProjectState` is created per workspace folder +- Two-phase semantic build: + - initial class/routine parse and symbol creation + - inheritance, variables, and call extraction +- Public symbols live in `GlobalSemanticModel` +- Private symbols are tracked through `LocalSemanticModel` and `ScopeTree` +- XML documents are tracked for diagnostics, but they do not enter the class/routine semantic rebuild pipeline + +## Workspace Layout + +- `objectscript-lsp`: LSP transport layer in [src/main.rs](src/main.rs), [src/lsp.rs](src/lsp.rs), and [src/server.rs](src/server.rs) +- `crates/objectscript-core`: parsing, semantic model, workspace state, refactors, and dependency tracking +- `objectscript-tests/`: fixture corpus for inheritance, dependencies, navigation, and related regressions + +## LSP Surface + +- Standard requests: + - `textDocument/definition` + - `textDocument/implementation` + - `textDocument/diagnostic` +- Code actions and execute commands for refactor rewrites + + +## Build and Test + +```bash +cargo build +cargo test +``` + +### Go-To Definition + +Go-to definition works for classes, class methods, orefs, procedures, subroutines, instance methods, public local variables, private local variables, and global variables. + +For variables, the way the definition(s) is determined depends on the case: +**CASE 1: Variable is defined the current scope. ** +In this case, the variable definition in the given scope is returned. + +**CASE 2: Variable is NOT defined the current scope. ** +**CASE 2A: Variable is Private** +This means that the variable is undefined. No definition is returned. +**CASE 2B: Variable is Public** +In this case, the `DependencyGraph` is used to determine all possible paths to the current scope. For each node (scope) on the path, we check if the wanted variable is defined in that scope, and if so we track that location. All possible locations are returned. + + +## Grammar Baseline + +- `tree-sitter = 0.26.6` +- `tree-sitter-objectscript = 1.9.12` +- `tree-sitter-objectscript-routine = 1.9.12` +- `tree-sitter-objectscript-playground = 1.9.12` +- `tree-sitter-xml = 0.7.0` + +## Roadmap + +- Semantic diagnostics (undefined variables, unresolved symbols) +- Broader mixed-language support beyond XML `Implementation` blocks +- More lifecycle and incremental edit coverage +- Expanded semantic support for properties, parameters, queries, triggers, and storage +- Find references and symbol-oriented LSP features +- Formatting support beyond the current refactor rewrites diff --git a/objectscript-lsp/crates/objectscript-core/Cargo.toml b/objectscript-lsp/crates/objectscript-core/Cargo.toml new file mode 100644 index 0000000..31bd113 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "objectscript-core" +version = "0.1.1" +edition = "2024" +authors = ["Hannah Kimura "] +license = "MIT" +description = "Language Server for ObjectScript" + +[dependencies] +tower-lsp = "0.20.0" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "fs"] } +tree-sitter = "0.26.6" +tree-sitter-objectscript = "1.9.12" +tree-sitter-objectscript-routine = "1.9.12" +tree-sitter-xml = "0.7.0" +serde = "1.0.228" +serde_json = "1.0.145" +parking_lot = "0.12.5" +walkdir = "2.5.0" +regex = "1.12.3" +petgraph = "0.8.3" +evalexpr = "13.1.0" diff --git a/objectscript-lsp/crates/objectscript-core/src/class.rs b/objectscript-lsp/crates/objectscript-core/src/class.rs new file mode 100644 index 0000000..d43830a --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/class.rs @@ -0,0 +1,457 @@ +use crate::common::{ + get_keyword_and_value, get_member_name_from_root, get_node_children, get_string_at_byte_range, +}; +use crate::method::initial_build_method; +use crate::parse_structures::{Class, Language, Method, MethodRef, MethodType}; +use std::collections::HashMap; +use tree_sitter::{Node, Range}; + +/// Determines if a node kind terminates a routine method scope. +fn is_rtn_method_end(node_str: &str, compiled_header: bool) -> bool { + if compiled_header { + return node_str == "command_quit" + || node_str == "procedure" + || node_str == "tag_statement"; + } else { + return node_str == "command_quit" || node_str == "procedure"; + } +} +impl Class { + /// Creates a new `Class` with the given name and empty semantic state. + /// + /// Inheritance/imports/keywords/members are initialized to defaults; `active` is `true`. + pub fn new(name: String, is_rtn: bool) -> Self { + Self { + name, + imports: Vec::new(), + inherited_classes: Vec::new(), + inheritance_direction: "left".to_string(), + is_procedure_block: None, + default_language: None, + methods: HashMap::new(), + private_properties: HashMap::new(), + public_properties: HashMap::new(), + parameters: HashMap::new(), + active: true, + is_rtn, + next_method_id: 0, + } + } + + /// Resets this `Class` to a clean state and sets its `name` and `active` flag. + /// + /// Clears imports/inheritance/keywords/methods/properties/params/method_calls and restores + /// default inheritance direction to `"left"`. + pub fn clear(&mut self, class_name: String, active: bool) { + self.name = class_name; + self.imports = Vec::new(); + self.inherited_classes = Vec::new(); + self.inheritance_direction = "left".to_string(); + self.is_procedure_block = None; + self.default_language = None; + self.methods = HashMap::new(); + self.private_properties = HashMap::new(); + self.public_properties = HashMap::new(); + self.parameters = HashMap::new(); + self.active = active; + self.next_method_id = 0; + } + + /// Allocates and returns the next sequential method ID for this class. + pub fn next_id(&mut self) -> usize { + let id = self.next_method_id; + self.next_method_id += 1; + id + } + + /// Resets this `Class` to a clean state for methods that have been changed. + /// + /// Clears parts of the class that has been changed + pub fn partial_clear( + &mut self, + class_name: String, + active: bool, + methods_to_remove: Vec, + ) { + self.name = class_name; + self.imports = Vec::new(); + self.inherited_classes = Vec::new(); + self.inheritance_direction = "left".to_string(); + self.is_procedure_block = None; + self.default_language = None; + for method_name in methods_to_remove { + self.methods.remove(&method_name); + } + self.private_properties = HashMap::new(); + self.public_properties = HashMap::new(); + self.parameters = HashMap::new(); + self.active = active; + } + + /// Extracts class keywords (ProcedureBlock, Language, InheritanceDirection) and collects + /// method definitions from the class body. Does not compute imports, include files, or + /// inherited/transitive semantics; those are handled later. + /// + /// Returns the parsed methods and their source ranges. + pub fn cls_initial_build( + &mut self, + node: Node, + content: &str, + methods: &mut Vec<(Method, Range, usize)>, + ) { + let class_children = get_node_children(node); + if class_children.len() < 2 { + eprintln!( + "initial_build: expected class_definition node, got kind={} named_children={}", + node.kind(), + class_children.len() + ); + return; + } + // skip keyword_class and class_name + for node in class_children.iter().skip(2) { + match node.kind() { + "class_keyword" => { + let Some(class_keyword_str) = + get_string_at_byte_range(content, node.byte_range()) + else { + eprintln!( + "Couldn't get string class keyword node, continuing (initial build)" + ); + continue; + }; + let (not, keyword_name, values) = + get_keyword_and_value(class_keyword_str.as_str()); + if keyword_name == "procedureblock" { + if not { + self.is_procedure_block = Some(false); + } else { + self.is_procedure_block = Some(true); + } + } else if keyword_name == "language" { + let Some(value) = values.get(0).copied() else { + eprintln!("Error: Expected a value for language keyword, got: None"); + continue; + }; + if value == "objectscript" { + self.default_language = Some(Language::Objectscript); + } else if value == "tsql" { + self.default_language = Some(Language::TSql); + } else { + eprintln!( + "Error: Expected class keyword language to be 'objectscript' or 'tsql', got: {}", + value + ); + continue; + } + } else if keyword_name == "inheritance" { + let Some(value) = values.get(0).copied() else { + eprintln!("Error: Expected a value for inheritance keyword, got: None"); + continue; + }; + if value == "right" { + self.inheritance_direction = "right".to_string(); + } else if value == "left" { + self.inheritance_direction = "left".to_string(); + } else { + eprintln!( + "Error: Expected class keyword inheritance to be 'right' or 'left', got: {}", + value + ); + continue; + } + } + } + "class_body" => { + let class_statements = get_node_children(node.clone()); + + // each child is a class statement + for class_statement in class_statements { + let Some(statement_type) = class_statement.named_child(0) else { + eprintln!( + "Error: class statement node {:?} has no child at index 0", + class_statement.kind() + ); + continue; + }; + match statement_type.kind() { + "method" | "classmethod" => { + let Some((method, method_range)) = + self.handle_class_statement_method(statement_type, content) + else { + eprintln!( + "Error: Failed to get method from handle_class_statement_method" + ); + continue; + }; + let method_id = self.next_id(); + methods.push((method, method_range, method_id)); + } + _ => { + continue; + } + } + } + } + _ => { + continue; + } + } + } + } + + /// Parses the tree of an ObjectScript Routine file. Extracts subroutines and procedures, + /// and builds corresponding Method structs to semantically represent them. + /// + /// Returns the parsed methods and their ranges. + pub fn rtn_initial_build( + &mut self, + node: Node, + content: &str, + methods: &mut Vec<(Method, Range, usize)>, + ) { + let Some(routine_name) = get_member_name_from_root(content, node, true) else { + return; + }; + let mut curr_routine_child = node.named_child(0); + while let Some(routine_child) = curr_routine_child { + match routine_child.kind() { + "routine_definition" | "compiled_header" => { + let mut saw_first_tag_statement = false; + let is_compiled_header = routine_child.kind() == "compiled_header"; + // get statement siblings until one is tag_statement or procedure + let mut next_sibling = routine_child.next_named_sibling(); + let routine_start_point = routine_child.start_position(); + let routine_start_byte = routine_child.start_byte(); + let mut routine_scope_end_point = node.end_position(); + let mut routine_scope_end_byte = node.end_byte(); + while let Some(sib) = next_sibling { + if sib.kind() == "statement" { + if let Some(future_statement_type) = sib.named_child(0) { + if !is_compiled_header || saw_first_tag_statement { + if is_rtn_method_end( + future_statement_type.kind(), + is_compiled_header, + ) { + break; + } + } else if future_statement_type.kind() == "tag_statement" { + saw_first_tag_statement = true + } + } + } + routine_scope_end_point = sib.end_position(); + routine_scope_end_byte = sib.end_byte(); + next_sibling = sib.next_named_sibling(); + } + let routine_range = Range { + start_byte: routine_start_byte, + start_point: routine_start_point, + end_point: routine_scope_end_point, + end_byte: routine_scope_end_byte, + }; + let routine_method = Method::new( + routine_name.clone(), + Some(false), + None, + crate::parse_structures::CodeMode::Code, + true, + None, + Vec::new(), + MethodType::Routine, + ); + let method_id = self.next_id(); + methods.push((routine_method, routine_range, method_id)); + curr_routine_child = routine_child.next_named_sibling(); + } + "statement" => { + let Some(statement_type) = routine_child.named_child(0) else { + eprintln!("Error: Expected Statement node to have child at index 0"); + curr_routine_child = routine_child.next_named_sibling(); + continue; + }; + if statement_type.kind() == "tag_statement" { + let mut is_public = true; + let Some(tag) = statement_type.named_child(0) else { + eprintln!("Error: expected tag statement node to have child at node 0"); + curr_routine_child = routine_child.next_named_sibling(); + continue; + }; + + let Some(name) = get_string_at_byte_range(content, tag.byte_range()) else { + curr_routine_child = routine_child.next_named_sibling(); + continue; + }; + if let Some(child) = statement_type + .named_child((statement_type.named_child_count() - 1) as u32) + { + match child.kind() { + "keyword_methodimpl" => { + eprintln!( + "TODO: Verify if there is anything to be done for methodimpl keyword" + ); + } + "keyword_private" => { + is_public = false; + } + _ => {} + } + } + // get statement siblings until one is tag_statement or procedure + let mut next_sibling = routine_child.next_named_sibling(); + let subroutine_start_point = statement_type.start_position(); + let subroutine_start_byte = statement_type.start_byte(); + let mut subroutine_scope_end_point = node.end_position(); + let mut subroutine_scope_end_byte = node.end_byte(); + while let Some(sib) = next_sibling { + if sib.kind() == "statement" { + if let Some(future_statement_type) = sib.named_child(0) { + if is_rtn_method_end(future_statement_type.kind(), false) { + break; + } + } + } + subroutine_scope_end_point = sib.end_position(); + subroutine_scope_end_byte = sib.end_byte(); + next_sibling = sib.next_named_sibling(); + } + let subroutine_range = Range { + start_byte: subroutine_start_byte, + start_point: subroutine_start_point, + end_point: subroutine_scope_end_point, + end_byte: subroutine_scope_end_byte, + }; + let subroutine_method = Method::new( + name.clone(), + Some(false), + None, + crate::parse_structures::CodeMode::Code, + is_public, + None, + Vec::new(), + MethodType::Subroutine, + ); + let method_id = self.next_id(); + methods.push((subroutine_method, subroutine_range, method_id)); + curr_routine_child = routine_child.next_named_sibling(); + // subroutine + } else if statement_type.kind() == "procedure" { + let Some(tag) = statement_type.named_child(0) else { + eprintln!( + "Expected procedure node to have a child at index 0, aborting initial_build_procedure" + ); + curr_routine_child = routine_child.next_named_sibling(); + continue; + }; + let Some(name) = get_string_at_byte_range(content, tag.byte_range()) else { + curr_routine_child = routine_child.next_named_sibling(); + continue; + }; + let procedure_range = statement_type.range(); + let mut is_public = false; + let mut public_variables_declared = Vec::new(); + let procedure_children = get_node_children(statement_type); + for procedure_statement in procedure_children { + match procedure_statement.kind() { + "procedure_pub_vars" => { + let variables = get_node_children(procedure_statement); + for var in variables { + let Some(var_name) = + get_string_at_byte_range(content, var.byte_range()) + else { + continue; + }; + public_variables_declared.push(var_name) + } + } + "keyword_public" => { + is_public = true; + } + _ => { + continue; + } + } + } + let procedure_method = Method::new( + name.clone(), + Some(true), + None, + crate::parse_structures::CodeMode::Code, + is_public, + None, + public_variables_declared, + MethodType::Procedure, + ); + let method_id = self.next_id(); + methods.push((procedure_method, procedure_range, method_id)); + curr_routine_child = routine_child.next_named_sibling(); + } else { + curr_routine_child = routine_child.next_named_sibling(); + continue; + } + } + _ => { + curr_routine_child = routine_child.next_named_sibling(); + continue; + } + } + } + } + + /// Performs the first-pass parse of an ObjectScript Routine or Cls Document into this `Class`. + /// + pub fn initial_build( + &mut self, + node: Node, + content: &str, + is_rtn: bool, + ) -> Vec<(Method, Range, usize)> { + let mut methods = Vec::new(); + if !is_rtn { + self.cls_initial_build(node, content, &mut methods); + } else { + self.rtn_initial_build(node, content, &mut methods); + } + methods + } + + /// Parses a `method` or `classmethod` node and returns the corresponding `Method` and its `Range`. + /// + /// Supports instance methods (`method`) and class methods (`classmethod`). Logs and returns + /// `None` for unsupported statement kinds or malformed syntax nodes. + fn handle_class_statement_method( + &mut self, + node: Node, + content: &str, + ) -> Option<(Method, Range)> { + let Some(method_definition) = node.named_child(1) else { + eprintln!( + "Error: Failed to get method definition from node {:?}. Aborting handle_class_statement_method.", + node.kind() + ); + return None; + }; + match node.kind() { + "method" => { + initial_build_method(method_definition, MethodType::InstanceMethod, content) + } + "classmethod" => { + initial_build_method(method_definition, MethodType::ClassMethod, content) + } + _ => { + eprintln!( + "Error: expected method or classmethod node, but got {:?}, aborting handle_class_statement_method.", + node.kind() + ); + None + } + } + } + + /// Returns the `PublicMethodId` for `method_name`, if this class declares it as public. + /// + /// Logs and returns `None` if the method is not present in `public_methods`. + pub fn get_method_ref(&self, method_name: &str) -> Option<&MethodRef> { + self.methods.get(method_name) + } +} diff --git a/objectscript-lsp/crates/objectscript-core/src/common.rs b/objectscript-lsp/crates/objectscript-core/src/common.rs new file mode 100644 index 0000000..ee1f287 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/common.rs @@ -0,0 +1,1174 @@ +use crate::parse_structures::{ClassId, MemberType, ReturnType}; +use crate::refactor::count_leading_dots_in_line; +use crate::scope_structures::ScopeId; +use crate::scope_tree::ScopeTree; +use regex::Regex; +use std::ops::Range as CoreRange; +use tower_lsp::lsp_types::{Position, Range as LspRange, Url}; +use tree_sitter::{ + Node, Point, Query, QueryCursor, Range as TsRange, Range, StreamingIterator, Tree, TreeCursor, +}; + +use tree_sitter_xml::LANGUAGE_XML; + +const XML_OBJECTSCRIPT_INJECTIONS_QUERY: &str = r#" +( + element + (STag (Name) @_start_tag) + (content (CDSect (CData) @injection.content)) + (ETag (Name) @_end_tag) + (#eq? @_start_tag "Implementation") + (#eq? @_end_tag "Implementation") + (#set! injection.language "objectscript") +) +( + element + (STag (Name) @_start_tag) + (content (CharData) @injection.content) + (ETag (Name) @_end_tag) + (#eq? @_start_tag "Implementation") + (#eq? @_end_tag "Implementation") + (#set! injection.language "objectscript") +) +"#; + +/// Logs override resolution results for a method/superclass pair for debugging. +pub fn print_statements_exit_method_overrides_fn( + method_name: &str, + superclass_name: &str, + locations: Vec<(Url, Range)>, +) { + if locations.is_empty() { + eprintln!( + "Leaving ProjectData function: get_variable_symbol_location.., there are no overrides of method {:?} from superclass {:?}", + method_name, superclass_name + ); + eprintln!("------------------------"); + eprintln!(); + return; + } + eprintln!( + "Leaving ProjectData function: get_variable_symbol_location.. the number of method implementations of the method named: {:?} in the superclass {:?} are: \n {:?}", + method_name, + superclass_name, + locations.len() + ); + eprintln!("------------------------"); + eprintln!(); +} + +/// Converts a tree-sitter `Point` (UTF-8 byte column) to an LSP `Position` (UTF-16 code-unit offset). +pub fn point_to_lsp_position(text: &str, p: Point) -> Position { + let starts = line_starts(text); + let (line_start, _line_end_incl, line_end_excl) = line_bounds(text, &starts, p.row); + + // If point is on EOF row, map to UTF-16 character 0 + if line_start == text.len() && line_end_excl == text.len() { + eprintln!("Info: Point is on EOF row, mapping to UTF-16 character 0"); + return Position { + line: p.row as u32, + character: 0, + }; + } + + // Clamp column to visible line (exclude '\n') + let max_col = line_end_excl.saturating_sub(line_start); + let target_col = p.column.min(max_col); + + let line = &text[line_start..line_end_excl]; + + let mut bytes = 0usize; + let mut utf16_units = 0u32; + + for ch in line.chars() { + let ch_bytes = ch.len_utf8(); + if bytes + ch_bytes > target_col { + break; + } + bytes += ch_bytes; + utf16_units += ch.len_utf16() as u32; + if bytes == target_col { + break; + } + } + Position { + line: p.row as u32, + character: utf16_units, + } +} + +fn line_starts(text: &str) -> Vec { + let mut starts = Vec::new(); + starts.push(0); // line 0 starts at byte 0 + + for (i, b) in text.as_bytes().iter().enumerate() { + if *b == b'\n' { + starts.push(i + 1); // next line starts right after '\n' + } + } + + starts.push(text.len()); + + starts +} + +/// Returns byte bounds for a specific line in `text` using a precomputed line-start table. +/// +/// `starts` is a slice of byte offsets where each element is the start index of a line. +/// It must include a final **sentinel** entry equal to `text.len()` (or the byte offset +/// immediately after the last line), so `starts.len() == number_of_lines + 1`. +/// +/// For a valid `row` (0-based), this function returns: +/// - `start`: the byte offset where line `row` begins. +/// - `end_incl`: the byte offset of the start of the *next* line (i.e. one past the end of this line, +/// including the trailing `\n` if present). +/// - `end_excl`: the byte offset one past the end of the line content, excluding a trailing `\n` if present. +/// +/// If `row` is out of range (`row >= starts.len() - 1`), returns `(len, len, len)` where `len = text.len()`. +/// +/// - If `starts` is missing the sentinel, too short, or contains out-of-range offsets, +/// prints a warning and returns `(len, len, len)`. +fn line_bounds(text: &str, starts: &[usize], row: usize) -> (usize, usize, usize) { + let len = text.len(); + + // Need at least one line start + sentinel + if starts.len() < 2 { + eprintln!( + "Error: line_bounds: invalid starts table (len={}), expected at least 2 (including sentinel).", + starts.len() + ); + return (len, len, len); + } + + // Sentinel should typically be == text.len() + let sentinel = *starts.last().unwrap(); + if sentinel > len { + eprintln!( + "Error: line_bounds: sentinel {} out of bounds for text len {}.", + sentinel, len + ); + return (len, len, len); + } + + let eof_row = starts.len() - 1; // last entry is sentinel + if row >= eof_row { + // out of range row => "EOF bounds" + return (len, len, len); + } + + let start = starts[row]; + let end_incl = starts[row + 1]; + + // Validate monotonic + in-bounds + if start > end_incl || end_incl > len { + eprintln!( + "Error: line_bounds: invalid bounds for row {}: start={}, end_incl={}, text_len={}.", + row, start, end_incl, len + ); + return (len, len, len); + } + + let end_excl = if end_incl > start && text.as_bytes().get(end_incl - 1) == Some(&b'\n') { + end_incl - 1 + } else { + end_incl + }; + + (start, end_incl, end_excl) +} + +/// Converts an LSP `Position` (line + UTF-16 character offset) into a Tree-sitter `Point` +/// (row + UTF-8 byte column) for the given source `text`. +/// +/// LSP positions encode `character` as a count of UTF-16 code units from the start of the line. +/// Tree-sitter points encode `column` as a byte offset (UTF-8) from the start of the line. +/// This function bridges those two coordinate systems by: +/// 1) locating the requested line bounds in `text`, and +/// 2) walking the line’s Unicode scalar values to convert a UTF-16 offset into a UTF-8 byte column. +/// +/// If `position.line` is at or beyond EOF (as determined by `line_bounds`), this returns an +/// EOF-like point with `{ row, column: 0 }`. +/// +/// If `position.character` lands in the middle of a surrogate pair boundary (i.e. between the +/// two UTF-16 code units used by a single non-BMP character), the conversion stops early and +/// returns the column at the start of that character (it does not split the pair). +/// +/// # Notes +/// - `row` is zero-based, matching both LSP and Tree-sitter. +/// - The returned `column` is a byte offset within the line (0-based). +pub fn position_to_point(text: &str, position: Position) -> Point { + let starts = line_starts(text); + let row = position.line as usize; + + let (line_start, _line_end_incl, line_end_excl) = line_bounds(text, &starts, row); + + // If row is EOF (or beyond), return EOF point + if line_start == text.len() && line_end_excl == text.len() { + return Point { row, column: 0 }; + } + + let line = &text[line_start..line_end_excl]; + + // Convert UTF-16 units to byte offset within this line + let mut remaining = position.character as usize; + let mut col_bytes = 0usize; + + for ch in line.chars() { + if remaining == 0 { + break; + } + let u16 = ch.len_utf16(); + if remaining < u16 { + break; // don't split a surrogate pair + } + remaining -= u16; + col_bytes += ch.len_utf8(); + } + Point { + row, + column: col_bytes, + } +} + +/// Converts a Tree-sitter `Range` into an LSP `Range` for the given source `text`. +/// +/// Tree-sitter ranges are expressed as start/end `Point`s where the `column` is a UTF-8 byte +/// offset within the line. LSP ranges are expressed as start/end `Position`s where the +/// `character` is a UTF-16 code-unit offset within the line. +/// +/// This function performs the conversion by translating both `start_point` and `end_point` +/// via `point_to_lsp_position`. +pub fn ts_range_to_lsp_range(text: &str, r: TsRange) -> LspRange { + let start = point_to_lsp_position(text, r.start_point); + let end = point_to_lsp_position(text, r.end_point); + LspRange { start, end } +} + +/// Converts a Tree-sitter `Point` (row + UTF-8 byte column) into an absolute byte offset +/// into `text`. +/// +/// This function uses a precomputed line-start table (`line_starts`) and `line_bounds` to: +/// - find the start of `point.row`, +/// - interpret `point.column` as a UTF-8 byte offset within that line, and +/// - return the absolute byte index `line_start + column`. +/// +/// Behavior at boundaries: +/// - If `point.row` is at or beyond the EOF row (based on the sentinel in `line_starts`), +/// this returns `text.len()`. +/// - The column is clamped to the end of the line content (excluding a trailing `'\n'`), +/// so the returned offset will not point past the line’s non-newline characters. +/// +/// # Notes +/// - `point.row` and `point.column` are both zero-based. +/// - The returned value is a byte index into `text` (suitable for slicing on UTF-8 +/// boundaries, assuming `point.column` came from Tree-sitter / valid byte columns). +pub fn point_to_byte(text: &str, point: Point) -> usize { + let starts = line_starts(text); + + // starts has a sentinel at text.len(), so EOF row is starts.len() - 1 + let eof_row = starts.len().saturating_sub(1); + + // If point is on EOF row (or beyond), it's EOF byte offset + if point.row >= eof_row { + eprintln!("Info: reached the EOF row"); + return text.len(); + } + + let (line_start, _line_end_incl, line_end_excl) = line_bounds(text, &starts, point.row); + + // Clamp column to the visible line (excluding '\n') + let max_col = line_end_excl.saturating_sub(line_start); + let col = point.column.min(max_col); + line_start + col +} + +/// Advances `(row, column)` by `changed_text`, returning the resulting `Point`. +/// +/// Newlines increment `row` and reset `column`; other chars add their UTF-8 byte length. +pub fn advance_point(mut row: usize, mut column: usize, changed_text: &str) -> Point { + for c in changed_text.chars() { + if c == '\n' { + row += 1; + column = 0; + } else { + column += c.len_utf8(); + } + } + Point { row, column } +} + +/// Returns a Vec of all named children nodes for a given Tree Sitter Node. +pub fn get_node_children(node: Node) -> Vec { + let mut cursor = node.walk(); + let result = node.named_children(&mut cursor).collect::>(); + result +} + +/// Given a Node, finds if there is a class definition child node. If so, returns that. +pub fn find_class_definition(root: Node) -> Option { + let mut cursor = root.walk(); + let result = root + .named_children(&mut cursor) + .find(|n| n.kind() == "class_definition"); + match result { + None => { + eprintln!("Error: Could not find class definition node from tree.",); + result + } + Some(_) => result, + } +} + +/// Dispatches to class or routine name extraction based on the `is_rtn` flag. +pub fn get_member_name_from_root(content: &str, node: Node, is_rtn: bool) -> Option { + return if is_rtn { + get_routine_name_from_root(content, node) + } else { + get_class_name_from_root(content, node) + }; +} + +/// Extracts the class name from a parsed Tree-sitter root `node`. +/// +/// Finds the `class_definition` node (via `find_class_definition`), then reads the class name +/// from its second named child (index `1`) and slices it from `content` using the node’s byte range. +/// +/// Returns `None` if no class definition/name is found or if the byte range is invalid; prints a +/// warning on unexpected/mismatched structure. +fn get_class_name_from_root(content: &str, node: Node) -> Option { + let Some(class_def) = find_class_definition(node) else { + return None; + }; + let Some(name_node) = class_def.named_child(1) else { + eprintln!( + "Error: Expected Class name node to be at the class definition ({:?}) node's 1 index, but it was not", + class_def + ); + return None; + }; + + let Some(class_name) = get_string_at_byte_range(content, name_node.byte_range()) else { + eprintln!( + "Error: Failed to get class name from content: {:?} \n\n\n. Expected it to be at byte range {:?}", + content, name_node + ); + + return None; + }; + Some(class_name.to_string()) +} + +/// Finds the byte range of the top-level routine body (before the first subroutine/procedure). +pub fn get_routine_range(root: Node) -> Option { + let start_byte = root.start_byte(); + let start_position = root.start_position(); + let end_byte = root.end_byte(); + let end_position = root.end_position(); + let mut has_rtn_def = false; + let routine_children = get_node_children(root); + for routine_child in routine_children { + match routine_child.kind() { + "routine_definition" => has_rtn_def = true, + "statement" => { + let Some(command) = routine_child.named_child(0) else { + eprintln!( + "Statement node did not have a child at index 0, aborting (get_routine_range)" + ); + return None; + }; + if has_rtn_def + && (command.kind() == "command_quit" || command.kind() == "procedure") + { + return Some(Range { + start_byte, + end_byte, + start_point: start_position, + end_point: end_position, + }); + } else if !has_rtn_def + && (command.kind() == "command_quit" + || command.kind() == "procedure" + || command.kind() == "tag_statement") + { + return Some(Range { + start_byte, + end_byte, + start_point: start_position, + end_point: end_position, + }); + } + } + _ => return None, + } + } + None +} + +/// Given root node (source_file), find the routine name +fn get_routine_name_from_root(content: &str, root: Node) -> Option { + // either it starts as a statement or as a routine_def + if let Some(node) = root.named_child(0) { + match node.kind() { + "routine_definition" => { + let Some(name_node) = node.named_child(1) else { + eprintln!( + "Error: Routine definition node doesn't have a named child at index 0" + ); + return None; + }; + if name_node.kind() != "routine_name" { + eprintln!( + "Error: Expected routine def child node at index 0 to be routine_name" + ); + return None; + } + return get_string_at_byte_range(content, name_node.byte_range()); + } + "statement" | "compiled_header" => { + let routine_children = get_node_children(root); + for statement_node in routine_children { + if statement_node.kind() != "statement" { + eprintln!( + "Skipping {:?} in get_routine_name_from_root", + statement_node.kind() + ); + continue; + } + let Some(command) = statement_node.named_child(0) else { + eprintln!( + "Statement node did not have a child at index 0, continuing (get_routine_name_from_root)" + ); + continue; + }; + if command.kind() == "tag_statement" { + let Some(tag) = command.named_child(0) else { + eprintln!("Error: Expected tag_statement to have child at index 0"); + continue; + }; + return get_string_at_byte_range(content, tag.byte_range()); + } + } + } + _ => return None, + } + } + None +} + +/// Returns the substring for `range` (byte offsets) within `content`. +/// +/// Logs a warning and returns `None` if the range is out of bounds. +pub fn get_string_at_byte_range(content: &str, range: CoreRange) -> Option { + let Some(s) = content.get(range) else { + eprintln!("Error: Couldn't get string from given byte range"); + return None; + }; + Some(s.to_string()) +} + +/// Maps a type name string (e.g. InterSystems % types) to a `ReturnType`. +/// +/// Unrecognized names return `ReturnType::Other(typename)` and are logged as unimplemented. +pub fn find_return_type(typename: String) -> ReturnType { + return match typename.to_lowercase().as_str() { + "%exactstring" | "%enumstring" | "%string" | "%char" => ReturnType::String, + "%bigint" | "%smallint" | "%integer" | "%posixtime" | "%counter" => ReturnType::Integer, + "%tinyint" => ReturnType::TinyInteger, + "%binary" => ReturnType::Binary, + "%date" => ReturnType::Date, + "%double" => ReturnType::Double, + "%numeric" | "%time" => ReturnType::Number, + "%status" => ReturnType::Status, + "%sqlquery" => ReturnType::SqlQuery, + _ => { + eprintln!("Unimplemented return type: {:?}", typename); + ReturnType::Other(typename) + } + }; +} + +// given an expression node, parses the node and tries to evaluate the expression +pub fn evaluate_expr_from_node(offset: &str) -> Option { + let val = evalexpr::eval_int(offset).ok(); + if let Some(val) = val { + let new_val = val as usize; + return Some(new_val); + } + None +} +/// Given an expression node, find all var types and var references within it +pub fn find_var_dependencies( + node: Node, + content: &str, + var_dependencies: &mut Vec, +) -> (bool, Option) { + let expression_children = get_node_children(node); + let mut is_oref = false; + let mut curr_class = None; + for child in expression_children { + match child.kind() { + "expression" | "unary_expression" => { + let (check_is_oref, cls) = find_var_dependencies(child, content, var_dependencies); + if !is_oref { + is_oref = check_is_oref; + } + if curr_class.is_none() { + curr_class = cls; + } + } + "class_method_call" => { + if let Some(class_ref) = child.named_child(0) + && let Some(method_name_node) = child.named_child(1) + && let Some(class_name_node) = class_ref.named_child(1) + { + // this part will remove the strings and such (it grabs the actual $.identifier node) + if let Some(method_name) = method_name_node.named_child(0) + && let Some(class_name) = class_name_node.named_child(0) + { + if let Some(method_name) = + get_string_at_byte_range(content, method_name.byte_range()) + { + if method_name.eq_ignore_ascii_case("%new") { + is_oref = true; + curr_class = + get_string_at_byte_range(content, class_name.byte_range()); + } + } + } + } + } + "gvn" => { + let gvn_children = get_node_children(child); + for gvn_child in gvn_children { + if gvn_child.kind() == "identifier" { + if let Some(gvn_id) = + get_string_at_byte_range(content, gvn_child.byte_range()) + { + let var_name = gvn_id; + var_dependencies.push(var_name); + } + } + } + } + "lvn" => { + let Some(lvn_id_node) = child.named_child(0) else { + eprintln!("Parsing Error: lvn must have a child at index 0, update parsing"); + continue; + }; + if let Some(lvn_id) = get_string_at_byte_range(content, lvn_id_node.byte_range()) { + let var_name = lvn_id; + var_dependencies.push(var_name); + } + } + _ => continue, + } + } + (is_oref, curr_class) +} + +/// Given a *_keyword string, returns: +/// bool: true if not is before the keyword +/// String: the keyword name +/// Option: the value of the keyword (if one exists) +pub fn get_keyword_and_value(keyword: &str) -> (bool, String, Vec<&str>) { + let mut not = false; + let mut keyword_name = "".to_string(); + let mut keyword_value: Vec<&str> = Vec::new(); + // splits string by spaces or equal sign + let regex = Regex::new(r"[^\s=,()]+").unwrap(); + let mut count = 0; + let values: Vec<&str> = regex.find_iter(keyword).map(|m| m.as_str()).collect(); + for value in values { + count += 1; + let normalized_str = value.to_lowercase(); + if normalized_str == "not" { + not = true; + } else if count > 1 && !not { + keyword_value.push(value); + } else { + keyword_name = normalized_str.to_string(); + } + } + (not, keyword_name, keyword_value) +} + +/// Builds an initial `ScopeTree` skeleton from a parsed `Tree`. +/// +/// Creates a new `ScopeTree` rooted at `class_symbol_id`, then walks the syntax tree and adds +/// scopes for nodes considered "scope nodes" (see `cls_is_scope_node`). +pub fn initial_build_scope_tree( + tree: Tree, + class_symbol_id: ClassId, + content: &str, + is_rtn: bool, +) -> ScopeTree { + let mut scope_tree = ScopeTree::new(Some(class_symbol_id)); + let mut scope_stack = vec![scope_tree.root]; + + let root = tree.root_node(); + build_scope_skeleton(root, &mut scope_tree, &mut scope_stack, is_rtn, content); + + scope_tree +} + +/// Recursively traverses `node` and adds scope entries to `scope_tree`, maintaining a stack of +/// active scope ids in `scope_stack`. +fn build_scope_skeleton( + node: Node, + scope_tree: &mut ScopeTree, + scope_stack: &mut Vec, + is_rtn: bool, + content: &str, +) { + let is_scope; + let method_name; + if !is_rtn { + (is_scope, method_name) = cls_is_scope_node(node, content); + } else { + (is_scope, method_name) = rtn_is_scope_node(node, content); + } + if is_scope { + let scope_start; + let scope_end; + if !is_rtn { + scope_start = node.start_position(); + scope_end = node.end_position(); + } else { + (scope_start, scope_end) = get_routine_scope_node_range(node, content); + } + let Some(&parent) = scope_stack.last() else { + eprintln!( + "Error: Failed to get Scope Parent from scope stack, aborting build_scope_skeleton" + ); + return; + }; + let scope_id = scope_tree.add_scope(scope_start, scope_end, parent, false, method_name); + scope_stack.push(scope_id); + } + + let mut cursor = node.walk(); + for child in node.named_children(&mut cursor) { + build_scope_skeleton(child, scope_tree, scope_stack, is_rtn, content); + } + + if is_scope { + scope_stack.pop(); + } +} + +/// Returns `true` if `pos` lies in the half-open range `[start, end)`. +/// Returns `false` otherwise. +pub fn point_in_range(pos: Point, start: Point, end: Point) -> bool { + if pos >= start && pos < end { + return true; + }; + false +} + +/// Returns `true` if `node` is treated as a scope boundary in `.cls` parsing. +/// Returns `false` otherwise. +pub fn cls_is_scope_node(node: Node, content: &str) -> (bool, Option) { + let mut method_name_str = None; + let mut is_scope = false; + if node.kind() == "classmethod" || node.kind() == "method" { + if let Some(method_definition) = node.named_child(1) + && let Some(method_name) = method_definition.named_child(0) + && method_name.kind() == "method_name" + && let Some(method_id) = method_name.named_child(0) + { + method_name_str = get_string_at_byte_range(content, method_id.byte_range()); + } + is_scope = true; + } + (is_scope, method_name_str) +} + +/// Determines if a tree-sitter node starts a new subroutine scope in a routine file. +pub fn rtn_is_scope_node(node: Node, content: &str) -> (bool, Option) { + let mut method_name_str = None; + let mut is_scope = false; + if node.kind() == "tag_statement" { + let mut sib = node.parent().and_then(|p| p.prev_named_sibling()); + while let Some(sibling) = sib { + let Some(command) = sibling.named_child(0) else { + eprintln!( + "Sibling node did not have a child at index 0, skipping (rtn_is_scope_node)" + ); + sib = sibling.prev_named_sibling(); + continue; + }; + if command.kind() == "tag_statement" { + return (false, None); + } else if command.kind() == "procedure" || command.kind() == "command_quit" { + if let Some(tag) = node.named_child(0) { + method_name_str = get_string_at_byte_range(content, tag.byte_range()); + } + return (true, method_name_str); + } + sib = sibling.prev_named_sibling(); + } + // No previous tag or quit found — first subroutine in the file. + if let Some(tag) = node.named_child(0) { + method_name_str = get_string_at_byte_range(content, tag.byte_range()); + } + return (true, method_name_str); + } + if node.kind() == "procedure" { + if let Some(tag) = node.named_child(0) { + method_name_str = get_string_at_byte_range(content, tag.byte_range()); + } + is_scope = true; + } else if node.kind() == "dotted_statement" { + if let Some(sibling) = node.prev_named_sibling() + && sibling.kind() == "dotted_statement" + { + let Some(dotted_statement_line) = get_string_at_byte_range(content, node.byte_range()) + else { + return (is_scope, method_name_str); + }; + let depth = count_leading_dots_in_line(&dotted_statement_line); + let Some(sib_dotted_statement_line) = + get_string_at_byte_range(content, sibling.byte_range()) + else { + return (is_scope, method_name_str); + }; + let sib_depth = count_leading_dots_in_line(&sib_dotted_statement_line); + if depth > sib_depth { + is_scope = true; + } + } else { + is_scope = true; + } + if is_scope { + let mut curr_parent = node.parent(); + while let Some(parent) = curr_parent + && let Some(_) = parent.parent() + { + if parent.kind() == "procedure" { + if let Some(tag) = parent.named_child(0) { + method_name_str = get_string_at_byte_range(content, tag.byte_range()); + } + break; + } + curr_parent = parent.parent(); + } + + if method_name_str.is_none() { + if let Some(parent) = curr_parent { + let mut curr_sib = parent.prev_named_sibling(); + while let Some(sibling) = curr_sib { + let Some(command) = sibling.named_child(0) else { + eprintln!( + "Sibling node did not have a child at index 0, skipping (rtn_is_scope_node)" + ); + curr_sib = sibling.prev_named_sibling(); + continue; + }; + if command.kind() == "tag_statement" { + if let Some(tag) = sibling.named_child(0) { + method_name_str = + get_string_at_byte_range(content, tag.byte_range()); + } + } else if command.kind() == "command_quit" || command.kind() == "procedure" + { + break; + } + curr_sib = sibling.prev_named_sibling(); + } + } + } + } + } + (is_scope, method_name_str) +} + +/// Given either a tag_statement, procedure, or dotted_statement node, +/// returns the start and end point of the scope of that node. +pub fn get_routine_scope_node_range(node: Node, content: &str) -> (Point, Point) { + match node.kind() { + "procedure" => return (node.start_position(), node.end_position()), + "tag_statement" => { + let mut next_sibling = node.parent(); + let subroutine_start_point = node.start_position(); + let mut subroutine_scope_end_point = node.end_position(); + while let Some(sib) = next_sibling { + if sib.kind() == "statement" { + if let Some(future_statement_type) = sib.named_child(0) { + if future_statement_type.kind() == "command_quit" { + return (subroutine_start_point, future_statement_type.end_position()); + } else if future_statement_type.kind() == "procedure" { + return (subroutine_start_point, subroutine_scope_end_point); + } + } + } + subroutine_scope_end_point = sib.end_position(); + next_sibling = sib.next_named_sibling(); + } + return (subroutine_start_point, subroutine_scope_end_point); + } + "dotted_statement" => { + let dotted_statement_start_point = node.start_position(); + let mut dotted_statement_scope_end_point = node.end_position(); + let Some(dotted_statement_line) = get_string_at_byte_range(content, node.byte_range()) + else { + return ( + dotted_statement_start_point, + dotted_statement_scope_end_point, + ); + }; + let depth = count_leading_dots_in_line(&dotted_statement_line); + let mut next_sibling = node.next_named_sibling(); + while let Some(sib) = next_sibling { + if sib.kind() == "dotted_statement" { + let Some(line) = get_string_at_byte_range(content, sib.byte_range()) else { + next_sibling = sib.next_named_sibling(); + continue; + }; + let curr_depth = count_leading_dots_in_line(&line); + if curr_depth < depth { + break; + } + dotted_statement_scope_end_point = sib.end_position(); + next_sibling = sib.next_named_sibling(); + } + } + return ( + dotted_statement_start_point, + dotted_statement_scope_end_point, + ); + } + _ => { + eprintln!("Error: {:?} is not a scope node for routines", node.kind()); + return (node.start_position(), node.end_position()); + } + } +} + +/// Given an identifier node, determine if it represents a method name, class name, etc. +/// OPTIONS: +/// [Class, Relationship, Foreignkey, Parameter, Projection,Index,Xdata,Storage,Method, Query, Trigger] +pub fn get_outer_type_from_identifier(node: &Node) -> Option { + return match node.kind() { + "parameter_name" => Some(MemberType::Parameter), + "projection_name" => Some(MemberType::Projection), + "class_name" => { + let Some(class_name_parent) = node.parent() else { + eprintln!("Error: expected class_name node to have parent"); + return None; + }; + if class_name_parent.kind() == "class_definition" { + return Some(MemberType::ClassDef); + } else { + return Some(MemberType::Class); + } + } + "query_name" => Some(MemberType::Query), + "trigger_name" => Some(MemberType::Trigger), + "property_name" => Some(MemberType::Property), + "relationship_name" => Some(MemberType::Relationship), + "foreignkey_name" => Some(MemberType::Foreignkey), + "index_name" => Some(MemberType::Index), + "xdata_name" => Some(MemberType::Xdata), + "storage_name" => Some(MemberType::Storage), + "method_name" => { + let Some(method_name_parent) = node.parent() else { + eprintln!("Error: expected method_name node to have parent"); + return None; + }; + return match method_name_parent.kind() { + "oref_method" => { + let Some(oref_method_parent) = method_name_parent.parent() else { + eprintln!("Error: expected oref_method node to have parent"); + return None; + }; + if oref_method_parent.kind() == "relative_dot_method" { + return Some(MemberType::RelativeMethodCall); + } else { + return Some(MemberType::OrefMethod); + } + } + "routine_tag_call" | "print_argument" | "goto_argument" | "zbreak_location" => { + Some(MemberType::RoutineMethodCall) + } + "class_method_call" | "system_defined_function" => { + Some(MemberType::ClassMethodCall) + } + "method_definition" => Some(MemberType::MethodDef), + _ => None, + }; + } + "lvn" => { + let Some(lvn_parent) = node.parent() else { + eprintln!("Error: expected lvn node to have parent"); + return Some(MemberType::LocalVariable); + }; + match lvn_parent.kind() { + "oref_chain_expr" | "class_ref" => Some(MemberType::OrefMethod), + _ => Some(MemberType::LocalVariable), + } + } + "ssvn" => Some(MemberType::SystemMember), + "gvn" => Some(MemberType::GlobalVariable), + "routine_name" => Some(MemberType::Routine), + "line_ref" => Some(MemberType::RoutineMethodCall), + _ => None, + }; +} + +/// Parses a `line_ref` node into (routine_name, method_name, optional_offset). +pub fn parse_line_ref( + node: Node, + content: &str, + curr_class: String, +) -> (String, String, Option) { + let line_ref_children = get_node_children(node); + let mut method_name = None; + let mut routine_name = None; + let mut offset = None; + for line_ref_child in line_ref_children { + match line_ref_child.kind() { + "label_offset" => { + let Some(offset_expression) = line_ref_child.named_child(0) else { + eprintln!( + "Error: failed to get label offset for line ref, skipping (get_method_calls)" + ); + continue; + }; + if let Some(expr_str) = + get_string_at_byte_range(content, offset_expression.byte_range()) + { + offset = evaluate_expr_from_node(&expr_str); + } + } + "objectscript_identifier" | "objectscript_identifier_special" => { + // this should be the subroutine name + method_name = get_string_at_byte_range(content, line_ref_child.byte_range()); + } + "routine_ref" => { + if let Some(routine_name_node) = + line_ref_child.named_child((line_ref_child.named_child_count() - 1) as u32) + && routine_name_node.kind() == "routine_name" + { + routine_name = + get_string_at_byte_range(content, routine_name_node.byte_range()); + } + } + _ => continue, + } + } + let final_routine_name; + let final_method_name; + if let Some(routine_name) = routine_name { + final_routine_name = routine_name; + } else { + final_routine_name = curr_class.to_string(); + } + if let Some(method_name) = method_name { + final_method_name = method_name; + } else { + final_method_name = final_routine_name.clone(); + } + (final_routine_name, final_method_name, offset) +} + +/// Given Method_arg node, find if there is something that could be equivalent to an identifier and return that as a String if so +pub fn get_identifier_from_method_arg(node: Node, content: &str) -> Option { + if let Some(expression) = node.named_child(0) + && expression.kind() == "expression" + { + let Some(name_node) = expression.named_child(0) else { + eprintln!( + "Error: This node can't be an expression, it doesn't have any children {:?}", + expression.kind() + ); + return None; + }; + + match name_node.kind() { + "lvn" | "oref_chain_expr" | "string_literal" => { + let Some(name) = get_string_at_byte_range(content, node.byte_range()) else { + return None; + }; + return Some(name.trim_matches('"').to_string()); + } + + _ => { + eprintln!( + "Error: name was type {:?}. Update get_identifier_from_method_arg", + node.kind() + ); + return None; + } + } + } + return None; +} + +/// Logs a formatted "aborting function" debug message for early-exit diagnostics. +pub fn generic_exit_statements(struct_name: &str, function_name: &str) { + eprintln!( + "Aborting function early. Leaving {:?} function: {:?}", + struct_name, function_name + ); + eprintln!("------------------------"); + eprintln!(); +} + +/// Logs a formatted "skipping" debug message when a function bypasses a struct. +pub fn generic_skipping_statements(function_name: &str, struct_name: &str, struct_type: &str) { + eprintln!( + "Skipping applying the logic from {function_name} to {struct_type} named {struct_name}" + ); + eprintln!("------------------------"); + eprintln!(); +} + +/// Collects all ERROR and MISSING nodes from a tree-sitter parse tree. +pub fn collect_error_nodes<'tree>(root: Node<'tree>) -> Vec> { + let mut out = Vec::new(); + let mut cursor = root.walk(); + visit_errors(root, &mut cursor, &mut out); + out +} + +/// Extracts byte ranges of ObjectScript code embedded in XML `` CDATA sections. +pub fn xml_objectscript_implementation_ranges(root: Node, content: &str) -> Vec { + let language = LANGUAGE_XML.into(); + let Some(query) = Query::new(&language, XML_OBJECTSCRIPT_INJECTIONS_QUERY).ok() else { + return Vec::new(); + }; + let capture_names = query.capture_names(); + let mut cursor = QueryCursor::new(); + let mut ranges = Vec::new(); + let mut matches = cursor.matches(&query, root, content.as_bytes()); + + while let Some(query_match) = matches.next() { + for capture in query_match.captures { + let Some(name) = capture_names.get(capture.index as usize) else { + continue; + }; + if *name != "injection.content" { + continue; + } + push_non_empty_xml_content_range(capture.node, content, &mut ranges); + } + } + + ranges.sort_by_key(|range| (range.start_byte, range.end_byte)); + ranges.dedup_by_key(|range| (range.start_byte, range.end_byte)); + ranges +} + +fn push_non_empty_xml_content_range(node: Node, content: &str, out: &mut Vec) { + let Some(text) = get_string_at_byte_range(content, node.byte_range()) else { + return; + }; + if !text.trim().is_empty() { + out.push(node.range()); + } +} + +fn visit_errors<'tree>( + node: Node<'tree>, + cursor: &mut TreeCursor<'tree>, + out: &mut Vec>, +) { + if !node.has_error() && !node.is_error() && !node.is_missing() { + return; + } + + if node.is_error() || node.is_missing() { + out.push(node); + } + + if cursor.goto_first_child() { + loop { + let child = cursor.node(); + visit_errors(child, cursor, out); + if !cursor.goto_next_sibling() { + break; + } + } + cursor.goto_parent(); + } +} +/// Returns "\r\n" or "\n" based on which line ending the content uses. +pub fn detect_newline(content: &str) -> &'static str { + if content.contains("\r\n") { + "\r\n" + } else { + "\n" + } +} + +/// Extracts and trims statement text from byte ranges, skipping empty lines. +pub fn normalized_statement_lines( + content: &str, + statement_ranges: &[std::ops::Range], +) -> Option> { + let mut statements = Vec::new(); + for range in statement_ranges { + let statement = get_string_at_byte_range(content, range.clone())?; + let trimmed = statement.trim(); + if !trimmed.is_empty() { + statements.push(trimmed.to_string()); + } + } + Some(statements) +} + +/// Joins statement strings with a given indent prefix and newline separator. +pub fn indent_statement_lines(statements: &[String], indent: &str, newline: &str) -> String { + statements + .iter() + .map(|statement| indent_statement(statement.as_str(), indent, newline)) + .collect::>() + .join(newline) +} + +fn indent_statement(statement: &str, indent: &str, newline: &str) -> String { + let lines = statement.split(newline).collect::>(); + let nested_base_indent = lines + .iter() + .skip(1) + .filter(|line| !line.trim().is_empty()) + .map(|line| leading_whitespace_len(line)) + .min() + .unwrap_or(0); + + lines + .iter() + .enumerate() + .map(|(index, line)| { + let normalized = if index == 0 || nested_base_indent == 0 { + *line + } else { + line.get(nested_base_indent..).unwrap_or("") + }; + format!("{indent}{normalized}") + }) + .collect::>() + .join(newline) +} + +fn leading_whitespace_len(line: &str) -> usize { + line.char_indices() + .find(|(_, ch)| !ch.is_whitespace()) + .map(|(index, _)| index) + .unwrap_or(line.len()) +} + +/// Returns the leading whitespace string on the line containing `byte_index`. +pub fn line_indent_before(content: &str, byte_index: usize) -> String { + let line_start = content + .get(..byte_index) + .and_then(|prefix| prefix.rfind('\n').map(|index| index + 1)) + .unwrap_or(0); + let line_prefix = content.get(line_start..byte_index).unwrap_or(""); + let indent_end = line_prefix + .char_indices() + .find(|(_, ch)| !ch.is_whitespace()) + .map(|(index, _)| index) + .unwrap_or(line_prefix.len()); + line_prefix[..indent_end].to_string() +} diff --git a/objectscript-lsp/crates/objectscript-core/src/config.rs b/objectscript-lsp/crates/objectscript-core/src/config.rs new file mode 100644 index 0000000..a05fe31 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/config.rs @@ -0,0 +1,32 @@ +use serde::Deserialize; +/// Workspace/user configuration flags for the language server. +/// +/// Deserialized from config input (with defaults via `#[serde(default)]`) and used to enable +/// or disable optional features such as snippets, formatting, linting, and strict mode. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Config { + /// Enables completion items that include snippet text edits. + pub enable_snippets: bool, + + /// Enables document formatting support. + pub enable_formatting: bool, + + /// Enables lint/diagnostic checks. + enable_lint: bool, + + /// Enables stricter parsing/diagnostic behavior when supported. + enable_strict_mode: bool, +} + +impl Default for Config { + /// Returns the default configuration (all features enabled by default). + fn default() -> Self { + Self { + enable_strict_mode: true, + enable_formatting: true, + enable_lint: true, + enable_snippets: true, + } + } +} diff --git a/objectscript-lsp/crates/objectscript-core/src/dependency_tracker.rs b/objectscript-lsp/crates/objectscript-core/src/dependency_tracker.rs new file mode 100644 index 0000000..d83c858 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/dependency_tracker.rs @@ -0,0 +1,75 @@ +use crate::parse_structures::ClassId; +use crate::parse_structures::MethodRef; +use petgraph::graph::{DiGraph, NodeIndex}; +use petgraph::visit::{Dfs, Reversed}; +use std::collections::HashMap; + +/// Stores all subclasses that depend on a given class through inheritance. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Dependents { + pub dependent_classes: HashMap>, +} + +impl Dependents { + /// Creates an empty Dependents index. + pub fn new() -> Self { + Self { + dependent_classes: HashMap::new(), + } + } +} + +/// Directed graph of method call relationships (node = MethodRef, edge = caller->callee). +#[derive(Clone, Debug)] +pub struct DependencyGraph { + pub graph: DiGraph, + pub lookup: HashMap, +} + +impl DependencyGraph { + /// Creates an empty dependency graph. + pub fn new() -> Self { + Self { + graph: DiGraph::new(), + lookup: HashMap::new(), + } + } + + /// Returns the graph NodeIndex for a method, if it exists. + pub fn get_node(&self, method: MethodRef) -> Option<&NodeIndex> { + self.lookup.get(&method) + } + /// If node already exists, index is returned. Otherwise, node is added + /// to the graph. + pub fn get_or_add_node(&mut self, method: MethodRef) -> NodeIndex { + if let Some(&idx) = self.lookup.get(&method) { + return idx; + } + + let idx = self.graph.add_node(method); + self.lookup.insert(method, idx); + idx + } + + /// Returns all transitive callers of a method via reverse DFS. + pub fn all_ancestors(&self, target: NodeIndex) -> Vec { + let reversed = Reversed(&self.graph); + let mut dfs = Dfs::new(&reversed, target); + let mut ancestors = Vec::new(); + while let Some(node) = dfs.next(&reversed) { + if node != target { + ancestors.push(self.graph[node]); + } + } + ancestors + } + + /// Adds a caller->callee edge, creating nodes if needed. + pub fn add_edge(&mut self, caller: MethodRef, callee: MethodRef) { + let caller_idx = self.get_or_add_node(caller); + let callee_idx = self.get_or_add_node(callee); + + // caller -> callee + self.graph.update_edge(caller_idx, callee_idx, ()); + } +} diff --git a/objectscript-lsp/crates/objectscript-core/src/document.rs b/objectscript-lsp/crates/objectscript-core/src/document.rs new file mode 100644 index 0000000..9860f61 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/document.rs @@ -0,0 +1,50 @@ +use crate::parse_structures::{ClassId, FileType}; +use crate::scope_tree::*; +use tree_sitter::Tree; + +/// Holds the current text (`content`), its parsed Tree-sitter syntax tree (`tree`), +/// derived semantic and scope state (scope tree + loca). `version` is `None` until the +/// document has been synced with the client. +#[derive(Clone, Debug)] +pub struct Document { + /// Full file contents. + pub content: String, + /// Latest Tree-Sitter tree for this file. + pub tree: Tree, + /// LSP document version, `None` until document is opened. + pub version: Option, + /// Type of ObjectScript File: `.cls`, `.inc`, or `.mac` + pub file_type: FileType, + /// Keeps track of symbols (locations of class, private methods, variables) for the given file. + pub scope_tree: ScopeTree, + /// An ID that maps the the corresponding class for this file, if this is a `.cls` file. + pub class_id: Option, + /// Name of class for `.cls` files. + pub class_name: Option, +} + +impl Document { + /// Creates a new `Document` from parsed source state. + /// + /// Initializes the document text (`content`), syntax tree (`tree`), file metadata, and the + /// initial `ScopeTree`. Semantic ids (`local_semantic_model_id`, `class_id`) are set to `None` + /// and filled in during indexing/build steps. + pub fn new( + content: String, + tree: Tree, + file_type: FileType, + class_name: Option, + scope_tree: ScopeTree, + version: Option, + ) -> Self { + Self { + content, + tree, + version, + file_type, + scope_tree, + class_id: None, + class_name, + } + } +} diff --git a/objectscript-lsp/crates/objectscript-core/src/global_semantic.rs b/objectscript-lsp/crates/objectscript-core/src/global_semantic.rs new file mode 100644 index 0000000..4a8a3fe --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/global_semantic.rs @@ -0,0 +1,645 @@ +use crate::common::generic_exit_statements; +use crate::dependency_tracker::Dependents; +use crate::local_semantic::LocalSemanticModel; +use crate::override_index::OverrideIndex; +use crate::parse_structures::{ + Class, ClassId, DfsState, Language, Method, MethodRef, PublicVarId, Variable, VariableRef, +}; +use crate::scope_structures::{ClassGlobalSymbol, MethodGlobalSymbol, VariableGlobalSymbol}; +use std::collections::{HashMap, HashSet}; +use tower_lsp::lsp_types::Url; +use tree_sitter::Range; + +/// Holds the semantic information and symbols for classes, public methods, and public variables. +#[derive(Clone, Debug)] +pub struct GlobalSemanticModel { + /// Stores public variables per class. + pub variables: HashMap>, + /// Stores all classes in a workspace. + pub classes: HashMap, + /// Stores public methods per class. + pub methods: HashMap, + /// Stores all local semantic models in a workspace. + pub lsms: HashMap, + /// Stores all class symbols in a workspace. + pub class_defs: HashMap, + /// Stores Method Global Symbols per Class Global Symbol + pub method_defs: HashMap, + /// Stores Variable Global Symbols per Class Global Symbol + pub variable_defs: HashMap>, + next_class_id: usize, +} + +impl GlobalSemanticModel { + /// Creates an empty `GlobalSemanticModel` with all tables initialized. + /// + /// This initializes storage for classes, methods, variables, local semantic models, and + /// symbol-definition maps, but does not populate any semantic data. + pub fn new() -> Self { + Self { + variables: HashMap::new(), + classes: HashMap::new(), + methods: HashMap::new(), + lsms: HashMap::new(), + class_defs: HashMap::new(), + method_defs: HashMap::new(), + variable_defs: HashMap::new(), + next_class_id: 0, + } + } + + /// Given a Variable, adds the variable to the vec corresponding to the class and method the variable is defined in. + /// Returns PublicVarId, which corresponds to the index which the Variable is stored. + pub fn new_variable(&mut self, variable: Variable, method_ref: MethodRef) -> VariableRef { + let vars = self.variables.entry(method_ref).or_insert(Vec::new()); + let var_ref = VariableRef { + pub_id: Some(PublicVarId(vars.len())), + priv_id: None, + }; + vars.push(variable); + var_ref + } + + /// Given a Class, adds the class to the `self.classes` vec, returning ClassId, which + /// corresponds to the index that the Class is stored. + pub fn new_class(&mut self, class: Class, class_id: ClassId) { + self.classes.insert(class_id, class); + } + + /// Given a Method, adds the method to the vec corresponding to the class the method is defined in. + pub fn new_method(&mut self, method: Method, method_ref: MethodRef) { + self.methods.insert(method_ref, method); + } + + /// Inserts a new `LocalSemanticModel`, hashed by class name, to the global store `self.lsms` + pub fn new_local_semantic(&mut self, class_id: ClassId, local_semantic: LocalSemanticModel) { + self.lsms.insert(class_id, local_semantic); + } + + /// Returns a mutable reference to the local semantic model with the given id. + /// + /// Logs a warning and returns `None` if `lsm_id` is out of bounds. + pub fn get_local_semantic_mut( + &mut self, + class_id: &ClassId, + ) -> Option<&mut LocalSemanticModel> { + self.lsms.get_mut(class_id) + } + + /// Returns an immutable reference to the local semantic model with the given id. + /// + /// Logs a warning and returns `None` if `lsm_id` is out of bounds. + pub fn get_local_semantic(&self, class_id: &ClassId) -> Option<&LocalSemanticModel> { + self.lsms.get(class_id) + } + + /// Returns an immutable reference to the class at `index` in the classes table. + /// + /// Logs a warning and returns `None` if `index` is out of bounds. + pub fn get_class(&self, index: &ClassId) -> Option<&Class> { + self.classes.get(index) + } + + /// Returns the `ClassGlobalSymbol` at `index` in the class symbol table. + /// + /// Logs a warning and returns `None` if `index` is out of bounds. + pub fn get_class_symbol(&self, index: &ClassId) -> Option<&ClassGlobalSymbol> { + self.class_defs.get(index) + } + + /// Fetches a mutable reference to a method by `MethodRef`. + /// + /// Looks up the corresponding method for `method_ref` and then indexes into it. Logs and returns `None` + /// if the class has no recorded method for `MethodRef`. + pub fn get_mut_method(&mut self, method_ref: &MethodRef) -> Option<&mut Method> { + self.methods.get_mut(method_ref) + } + + /// Fetches an immutable reference to a method by `MethodRef`. + /// + /// Looks up the corresponding method for `method_ref` and then indexes into it. Logs and returns `None` + /// if the class has no recorded method for `MethodRef`. + pub fn get_method(&self, method_ref: &MethodRef) -> Option<&Method> { + self.methods.get(method_ref) + } + + /// Returns the `MethodGlobalSymbol` for a class symbol by symbol index. + /// + /// Logs and returns `None` if the class has no method symbols recorded or `method_symbol_id` is + /// out of bounds. + pub fn get_method_symbol_mut( + &mut self, + method_symbol_ref: &MethodRef, + ) -> Option<&mut MethodGlobalSymbol> { + self.method_defs.get_mut(method_symbol_ref) + } + + /// Returns the `MethodGlobalSymbol` for a class symbol by symbol index. + /// + /// Logs and returns `None` if the class has no method symbols recorded or `method_symbol_id` is + /// out of bounds. + pub fn get_method_symbol(&self, method_symbol_ref: &MethodRef) -> Option<&MethodGlobalSymbol> { + self.method_defs.get(method_symbol_ref) + } + + /// Returns the `VariableGlobalSymbol` for a class symbol by symbol index. + /// + /// Logs and returns `None` if the class has no variable symbols recorded or `index` is out of bounds. + pub fn get_variable_symbol( + &self, + method_symbol_ref: &MethodRef, + index: usize, + ) -> Option<&VariableGlobalSymbol> { + let Some(var_symbols) = self.variable_defs.get(method_symbol_ref) else { + return None; + }; + var_symbols.get(index) + } + + /// Returns the `VariableGlobalSymbol` for a MethodRef by symbol index. + /// + /// Logs and returns `None` if the class has no variable symbols recorded or `index` is out of bounds. + pub fn get_variable(&self, method_ref: &MethodRef, index: usize) -> Option<&Variable> { + let Some(variables) = self.variables.get(method_ref) else { + return None; + }; + variables.get(index) + } + + /// Clears all semantic state associated with a re-parsed document. + /// + /// Resets the class entry, removes method/variable tables for `class_id`, and clears the + /// associated local semantic model. Use this when a document is being reparsed, not deleted. + pub fn incremental_reset_doc_semantics( + &mut self, + class_id: &ClassId, + class_name: String, + methods_to_remove: Vec, + ) { + let Some(class) = self.classes.get_mut(class_id) else { + eprintln!("Error: class named {:?} not found", class_name); + return; + }; + + let mut method_names_to_remove = Vec::new(); + for method_ref in &methods_to_remove { + if let Some(method) = self.methods.remove(method_ref) { + method_names_to_remove.push(method.name.clone()); + } + self.variables.remove(&method_ref); + } + class.partial_clear(class_name.clone(), true, method_names_to_remove); + + // reset everything in the local semantic model + if let Some(local_semantic_model) = self.lsms.get_mut(class_id) { + local_semantic_model.partial_clear(methods_to_remove); + }; + } + + /// Clears all semantic state associated with a re-parsed document. + /// + /// Resets the class entry, removes method/variable tables for `class_id`, and clears the + /// associated local semantic model. Use this when a document is being reparsed, not deleted. + pub fn reset_doc_semantics(&mut self, class_id: &ClassId, class_name: String) { + let Some(class) = self.classes.get_mut(class_id) else { + eprintln!("Error: class named {:?} not found", class_name); + return; + }; + + for method_ref in class.methods.values() { + self.methods.remove(&method_ref); + self.variables.remove(&method_ref); + } + class.clear(class_name.clone(), true); + + // reset everything in the local semantic model + if let Some(local_semantic_model) = self.lsms.get_mut(&class_id) { + local_semantic_model.clear(); + }; + } + + pub fn next_id(&mut self) -> usize { + let id = self.next_class_id; + self.next_class_id += 1; + id + } + + /// Marks the class symbol as inactive and removes all method/variable symbols for the document. + pub fn remove_document_symbols( + &mut self, + class_symbol_id: &ClassId, + method_symbol_refs_to_remove: &Vec, + ) { + let Some(class_symbol) = self.class_defs.get_mut(class_symbol_id) else { + eprintln!("Error: in remove_document_symbols, Error: class symbol not found"); + return; + }; + for method_symbol_ref in method_symbol_refs_to_remove { + self.method_defs.remove(method_symbol_ref); + self.variable_defs.remove(method_symbol_ref); + } + class_symbol.alive = false; + } + + /// Updates an existing class symbol’s metadata and marks it as alive. + pub fn update_class_symbol( + &mut self, + name: String, + range: Range, + url: Url, + symbol_id: &ClassId, + ) { + let Some(symbol) = self.class_defs.get_mut(symbol_id) else { + eprintln!("Error: (In global_semantic, update_class_symbol) class symbol not found"); + return; + }; + symbol.alive = true; + symbol.name = name; + symbol.location = range; + symbol.url = url; + } + + /// Creates a new class symbol entry and returns its id. + pub fn new_class_symbol(&mut self, name: String, range: Range, url: Url, symbol_id: ClassId) { + self.class_defs.insert( + symbol_id, + ClassGlobalSymbol { + name, + url, + location: range, + alive: true, + }, + ); + } + + /// Adds a new method symbol under `class_symbol_id` and returns its per-class symbol id. + /// + /// Returns `None` (and logs) if the per-class method symbol table cannot be retrieved. + pub fn new_method_symbol( + &mut self, + name: String, + range: Range, + url: Url, + method_symbol_ref: MethodRef, + ) { + let method_symbol = MethodGlobalSymbol { + name, + url, + location: range, + references: Vec::new(), + method_dependencies: Vec::new(), + }; + self.method_defs.insert(method_symbol_ref, method_symbol); + } + + /// Adds a new variable symbol under `class_symbol_id` and returns its per-class symbol id. + /// + /// Returns `None` (and logs) if the per-class variable symbol table cannot be retrieved. + pub fn new_variable_symbol( + &mut self, + range: Range, + url: Url, + var_dependencies: Vec, + method_symbol_ref: MethodRef, + variable_ref: VariableRef, + ) { + let defs = self + .variable_defs + .entry(method_symbol_ref) + .or_insert(Vec::new()); + if let Some(id) = variable_ref.pub_id { + if defs.len() != id.0 { + eprintln!( + "ERROR: The index for the variables vec is not equivalent to the index for the variable symbol" + ); + } + defs.push(VariableGlobalSymbol { + url, + location: range, + var_dependencies, + }); + } + } + + /// Computes effective class keyword values (procedure block + default language) from inheritance. + /// + /// Fills only missing (`None`) values using the primary parent (leftmost) transitively, with + /// cycle protection via DFS state/memoization. + pub fn class_keyword_inheritance(&mut self) { + #[derive(Clone)] + struct Snap { + declared_pb: Option, + declared_lang: Option, + primary_parent: Option, // leftmost only + } + + let mut entries: Vec<(ClassId, &Class)> = + self.classes.iter().map(|(&id, c)| (id, c)).collect(); + entries.sort_by_key(|(id, _)| id.0); + + let id_to_idx: HashMap = entries + .iter() + .enumerate() + .map(|(i, (id, _))| (*id, i)) + .collect(); + + let class_ids: Vec = entries.iter().map(|(id, _)| *id).collect(); + + let snaps: Vec = entries + .iter() + .map(|(_, c)| Snap { + declared_pb: c.is_procedure_block, + declared_lang: c.default_language.clone(), + primary_parent: c.inherited_classes.get(0).copied(), + }) + .collect(); + + let n = snaps.len(); + let mut memo: Vec, Option)>> = vec![None; n]; + let mut state: Vec = vec![DfsState::Unvisited; n]; + + fn dfs( + idx: usize, + snaps: &Vec, + id_to_idx: &HashMap, + memo: &mut Vec, Option)>>, + state: &mut Vec, + ) -> (Option, Option) { + if let Some(v) = memo[idx].clone() { + return v; + } + + if state[idx] == DfsState::Visiting { + let s = &snaps[idx]; + return (s.declared_pb, s.declared_lang.clone()); + } + + state[idx] = DfsState::Visiting; + + let s = &snaps[idx]; + + // start with declared values + let mut pb = s.declared_pb; + let mut lang = s.declared_lang.clone(); + + // fill missing from primary parent transitively + if pb.is_none() || lang.is_none() { + if let Some(parent) = s.primary_parent { + if let Some(&parent_idx) = id_to_idx.get(&parent) { + let (ppb, plang) = dfs(parent_idx, snaps, id_to_idx, memo, state); + if pb.is_none() { + pb = ppb; + } + if lang.is_none() { + lang = plang; + } + } + } + } + + state[idx] = DfsState::Done; + memo[idx] = Some((pb, lang.clone())); + (pb, lang) + } + + // ---- Phase B: apply (only fill None) ---- + for i in 0..n { + let (eff_pb, eff_lang) = dfs(i, &snaps, &id_to_idx, &mut memo, &mut state); + let class_id = class_ids[i]; + let Some(cls) = self.classes.get_mut(&class_id) else { + continue; + }; + + if cls.is_procedure_block.is_none() { + cls.is_procedure_block = eff_pb; + } + if cls.default_language.is_none() { + cls.default_language = eff_lang; + } + } + } + + /// Build a reverse inheritance index for each class. + /// + /// For every class, this returns the transitive set of subclasses that depend on it via + /// `Extends`. Inactive classes are skipped. + pub fn build_dependents(&self) -> Dependents { + let mut entries: Vec<(ClassId, &Class)> = + self.classes.iter().map(|(&id, c)| (id, c)).collect(); + entries.sort_by_key(|(id, _)| id.0); + + let id_to_idx: HashMap = entries + .iter() + .enumerate() + .map(|(i, (id, _))| (*id, i)) + .collect(); + + let n = entries.len(); + let mut children: Vec> = vec![Vec::new(); n]; + + for (child_id, cls) in entries.iter() { + if !cls.active { + continue; + } + for parent_id in &cls.inherited_classes { + if let Some(&parent_idx) = id_to_idx.get(parent_id) { + if entries[parent_idx].1.active { + children[parent_idx].push(*child_id); + } + } + } + } + + let mut memo: Vec>> = vec![None; n]; + let mut state: Vec = vec![DfsState::Unvisited; n]; + let mut index = Dependents::new(); + + fn dfs( + idx: usize, + children: &Vec>, + id_to_idx: &HashMap, + memo: &mut Vec>>, + state: &mut Vec, + ) -> HashSet { + if let Some(cached) = &memo[idx] { + return cached.clone(); + } + + if state[idx] == DfsState::Visiting { + eprintln!("Cycle detected in inheritance graph"); + generic_exit_statements("GlobalSemanticModel", "build_dependents"); + return HashSet::new(); + } + + state[idx] = DfsState::Visiting; + + let mut table: HashSet = HashSet::new(); + for &child in &children[idx] { + table.insert(child); + if let Some(&child_idx) = id_to_idx.get(&child) { + table.extend(dfs(child_idx, children, id_to_idx, memo, state)); + } + } + + state[idx] = DfsState::Done; + memo[idx] = Some(table.clone()); + table + } + + for (i, (class_id, cls)) in entries.iter().enumerate() { + if !cls.active { + continue; + } + let mut dependents: Vec = dfs(i, &children, &id_to_idx, &mut memo, &mut state) + .into_iter() + .collect(); + dependents.sort_by_key(|class_id| class_id.0); + index.dependent_classes.insert(*class_id, dependents); + } + + index + } + + /// Builds an override/dispatch index for methods across the inheritance graph. + /// + /// Produces: + /// - per-class effective public method table, + /// - override relationships (`overrides` / `overridden_by`) for public and private declarations. + /// + /// IMPORTANT: `class.inherited_classes` must contain direct parents only when called. + pub fn build_override_index(&self) -> OverrideIndex { + #[derive(Clone)] + struct ClassSnap { + class_id: ClassId, + parents: Vec, + inheritance_direction: String, // "left" or "right" + public_methods: Vec<(String, MethodRef)>, // declared public methods in this class + private_methods: Vec<(String, MethodRef)>, + } + + let mut entries: Vec<(ClassId, &Class)> = + self.classes.iter().map(|(&id, c)| (id, c)).collect(); + entries.sort_by_key(|(id, _)| id.0); + + let id_to_idx: HashMap = entries + .iter() + .enumerate() + .map(|(i, (id, _))| (*id, i)) + .collect(); + + let snaps: Vec = entries + .iter() + .map(|(id, c)| ClassSnap { + class_id: *id, + parents: c.inherited_classes.clone(), + inheritance_direction: c.inheritance_direction.clone(), + public_methods: c + .methods + .iter() + .filter(|&(_, method_ref)| self.get_method(method_ref).is_some()) + .map(|(name, method_ref)| (name.clone(), method_ref.clone())) + .collect(), + private_methods: c + .methods + .iter() + .filter(|&(_, method_ref)| self.get_method(method_ref).is_none()) + .map(|(name, method_ref)| (name.clone(), method_ref.clone())) + .collect(), + }) + .collect(); + + let n = snaps.len(); + let mut memo: Vec>> = vec![None; n]; + let mut state: Vec = vec![DfsState::Unvisited; n]; + let mut index = OverrideIndex::new(); + + fn dfs( + idx: usize, + snaps: &Vec, + id_to_idx: &HashMap, + memo: &mut Vec>>, + state: &mut Vec, + index: &mut OverrideIndex, + ) -> HashMap { + if let Some(cached) = memo[idx].clone() { + return cached; + } + if state[idx] == DfsState::Visiting { + eprintln!("Cycle detected in inheritance graph"); + generic_exit_statements("GlobalSemanticModel", "build_override_index"); + return HashMap::new(); + } + + state[idx] = DfsState::Visiting; + + let snap = &snaps[idx]; + let cls_id = snap.class_id; + + // inherited effective table + let mut table: HashMap = HashMap::new(); + + let parent_iter: Box> = + if snap.inheritance_direction == "right" { + Box::new(snap.parents.iter().rev()) + } else { + Box::new(snap.parents.iter()) + }; + + for parent in parent_iter { + let Some(&parent_idx) = id_to_idx.get(parent) else { + continue; + }; + let parent_table = dfs(parent_idx, snaps, id_to_idx, memo, state, index); + for (name, mref) in parent_table { + table.entry(name).or_insert(mref); // first wins + } + } + + // overlay declared methods for this class + for (name, child_ref) in &snap.public_methods { + if let Some((base_ref, is_public)) = table.get(name).copied() + && is_public + { + index.overrides.insert(child_ref.clone(), base_ref); + index + .overridden_by + .entry(base_ref) + .or_default() + .push(child_ref.clone()); + } + table.insert(name.clone(), (*child_ref, true)); // child wins + } + + for (name, child_ref) in &snap.private_methods { + if let Some((base_ref, _)) = table.get(name).copied() { + index.overrides.insert(child_ref.clone(), base_ref); + index + .overridden_by + .entry(base_ref) + .or_default() + .push(child_ref.clone()); + } + + table.insert(name.clone(), (*child_ref, false)); // child wins + } + let effective_public: HashMap = table + .iter() + .filter(|&(_, (_, is_public))| *is_public) + .map(|(name, (method_ref, _))| (name.clone(), method_ref.clone())) + .collect(); + + index + .effective_public_methods + .insert(cls_id, effective_public); + + state[idx] = DfsState::Done; + memo[idx] = Some(table.clone()); + table + } + + for i in 0..n { + let _ = dfs(i, &snaps, &id_to_idx, &mut memo, &mut state, &mut index); + } + index + } +} diff --git a/objectscript-lsp/crates/objectscript-core/src/lib.rs b/objectscript-lsp/crates/objectscript-core/src/lib.rs new file mode 100644 index 0000000..a356b62 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/lib.rs @@ -0,0 +1,15 @@ +pub mod class; +pub mod common; +pub mod config; +pub mod dependency_tracker; +pub mod document; +pub mod global_semantic; +pub mod local_semantic; +pub mod method; +pub mod override_index; +pub mod parse_structures; +pub mod refactor; +pub mod scope_structures; +pub mod scope_tree; +pub mod variable; +pub mod workspace; diff --git a/objectscript-lsp/crates/objectscript-core/src/local_semantic.rs b/objectscript-lsp/crates/objectscript-core/src/local_semantic.rs new file mode 100644 index 0000000..f4455c5 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/local_semantic.rs @@ -0,0 +1,83 @@ +use crate::parse_structures::{ + ClassProperty, Method, MethodRef, PrivateVarId, Variable, VariableRef, +}; +use std::collections::HashMap; +/// Per-document private semantic state (methods, properties, variables). +/// +/// This is used for private members that should not be shared across classes globally. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LocalSemanticModel { + pub methods: HashMap, + pub properties: Vec, + pub variables: HashMap>, + pub active: bool, +} + +impl LocalSemanticModel { + /// Creates a new, empty `LocalSemanticModel` with `active` set to `true`. + pub fn new() -> Self { + Self { + methods: HashMap::new(), + properties: Vec::new(), + variables: HashMap::new(), + active: true, + } + } + + /// Removes methods and their variables by MethodRef, leaving the rest intact. + pub fn partial_clear(&mut self, methods_to_clear: Vec) { + for method_ref in methods_to_clear { + self.methods.remove(&method_ref); + self.variables.remove(&method_ref); + } + } + + /// Returns a reference to a private variable by method ref and index. + pub fn get_variable(&self, method_ref: &MethodRef, variable_index: usize) -> Option<&Variable> { + self.variables + .get(method_ref) + .and_then(|variables| variables.get(variable_index)) + } + + /// Clears all stored methods/properties/variables and marks the model as inactive. + pub fn clear(&mut self) { + self.methods.clear(); + self.properties.clear(); + self.variables.clear(); + self.active = false; + } + + /// Adds a new private/local variable to this model and returns its `PrivateVarId`. + /// + /// The returned id is the index of the variable in the internal `variables` vector. + pub fn new_variable(&mut self, method_ref: MethodRef, variable: Variable) -> VariableRef { + let curr_method_vars = self.variables.entry(method_ref).or_insert(Vec::new()); + let var_ref = VariableRef { + pub_id: None, + priv_id: Some(PrivateVarId(curr_method_vars.len())), + }; + curr_method_vars.push(variable); + var_ref + } + + /// Adds a new private/local method to this model and returns its `PrivateMethodId`. + /// + /// The returned id is the index of the method in the internal `methods` vector. + pub fn new_method(&mut self, method: Method, method_ref: MethodRef) { + self.methods.insert(method_ref, method); + } + + /// Returns an immutable reference to the private/local method at `private_method_id`. + /// + /// Logs a warning and returns `None` if the index is out of bounds. + pub fn get_method(&self, method_ref: &MethodRef) -> Option<&Method> { + self.methods.get(method_ref) + } + + /// Returns a mutable reference to the private/local method at `index`. + /// + /// Logs a warning and returns `None` if the index is out of bounds. + pub fn get_method_mut(&mut self, method_ref: &MethodRef) -> Option<&mut Method> { + self.methods.get_mut(method_ref) + } +} diff --git a/objectscript-lsp/crates/objectscript-core/src/method.rs b/objectscript-lsp/crates/objectscript-core/src/method.rs new file mode 100644 index 0000000..c499368 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/method.rs @@ -0,0 +1,390 @@ +use crate::common::{ + find_return_type, find_var_dependencies, generic_skipping_statements, get_keyword_and_value, + get_node_children, get_string_at_byte_range, +}; +use crate::parse_structures::{CodeMode, Language, Method, MethodType, ReturnType, Variable}; + +use std::collections::HashMap; +use tree_sitter::{Node, Range}; + +/// Builds a `Method` from its header/definition node (first-pass parse). +/// +/// Parses the method name, return type, and method keywords (ProcedureBlock/Language/CodeMode, +/// visibility, and public variable list). Does **not** parse the method body statements; those +/// are handled in a later pass. +/// +/// Returns the constructed `Method` and the source `Range` for the definition node. +pub fn initial_build_method( + node: Node, + method_type: MethodType, + content: &str, +) -> Option<(Method, Range)> { + let Some(method_name_node) = node.named_child(0) else { + eprintln!( + "Error: Expected method definition node to have child at index 0, aborting initial_build_method" + ); + return None; + }; + let Some(method_name) = get_string_at_byte_range(content, method_name_node.byte_range()) else { + return None; + }; + let method_range = node.range(); + let mut method_return_type = None; + let mut is_procedure_block = None; + let mut language = None; + let mut codemode = None; + let mut is_public = true; + let mut public_variables = Vec::new(); + let children = get_node_children(node.clone()); + if children.len() <= 1 { + eprintln!( + "Error: Expected method definition node to have more than one child, aborting initial_build_method" + ); + return None; + } + for node in children[1..].iter() { + match node.kind() { + "return_type" => { + let Some(type_name_node) = node.named_child(1) else { + eprintln!( + "Warning: Expected node of kind ({:?}) to have a child at index 1, but it doesn't", + node.kind() + ); + generic_skipping_statements("initial_build_method", node.kind(), "node"); + continue; + }; + let Some(typename) = get_string_at_byte_range(content, type_name_node.byte_range()) + else { + continue; + }; + method_return_type = Some(find_return_type(typename)); + } + "method_keyword" + | "method_keyword_codemode_expression" + | "call_method_keyword" + | "method_keyword_external_language" => { + let Some(keyword_str) = get_string_at_byte_range(content, node.byte_range()) else { + eprintln!("Error: Failed to get keyword string from byte range"); + continue; + }; + let (not, keyword_name, values) = get_keyword_and_value(keyword_str.as_str()); + if keyword_name == "procedureblock" { + if values.get(0).copied().is_none() { + is_procedure_block = Some(true); + continue; + } + let Some(value) = values.get(0).copied() else { + eprintln!("Error: Expected a value for procedureblock keyword, got: None"); + continue; + }; + if value == "1" { + is_procedure_block = Some(true); + } else if value == "0" { + is_procedure_block = Some(false); + } else { + eprintln!( + "Error: Expected procedureblock value to be '1' or '0', got: {}", + value + ); + continue; + } + } else if keyword_name == "language" { + let Some(value) = values.get(0).copied() else { + eprintln!("Error: Expected a value for language keyword, got: None"); + continue; + }; + if value == "objectscript" { + language = Some(Language::Objectscript); + } else if value == "tsql" { + language = Some(Language::TSql); + } else if value == "ispl" { + language = Some(Language::ISpl); + } else if value == "python" { + language = Some(Language::Python); + } else { + eprintln!( + "Error: Expected class keyword language to be 'objectscript' or 'tsql', got: {}", + value + ); + continue; + } + } else if keyword_name == "private" { + if not { + is_public = true; + } else { + is_public = false; + } + } else if keyword_name == "codemode" { + let Some(value) = values.get(0).copied() else { + eprintln!("Expected a value for language keyword, got: None"); + continue; + }; + if value == "call" { + codemode = Some(CodeMode::Call); + } else if value == "code" { + codemode = Some(CodeMode::Code); + } else if value == "expression" { + codemode = Some(CodeMode::Expression); + } else if value == "objectgenerator" { + codemode = Some(CodeMode::ObjectGenerator); + } else { + eprintln!( + "Expected class keyword codemode to be 'call', 'code', 'expression', or 'objectgenerator', got: {}", + value + ); + continue; + } + } else if keyword_name == "publiclist" { + for variable in values { + public_variables.push(variable.to_string()); + } + } + } + _ => { + // only parse the header for initial build + continue; + } + } + } + let method = Method::new( + method_name.clone(), + is_procedure_block, + language, + codemode.unwrap_or(CodeMode::Code), + is_public, + method_return_type, + public_variables, + method_type, + ); + Some((method, method_range)) +} + +impl Method { + /// Creates a new `Method` from parsed header information. + /// + /// Initializes empty variable tables and stores declared keywords/visibility/type metadata. + pub fn new( + method_name: String, + is_procedure_block: Option, + language: Option, + code_mode: CodeMode, + is_public: bool, + return_type: Option, + public_variables: Vec, + method_type: MethodType, + ) -> Self { + Self { + method_type, + return_type, + name: method_name, + variables: HashMap::new(), + is_public, + is_procedure_block, + language, + code_mode, + public_variables_declared: public_variables, + } + } + + /// Parses a method definition node to extract variables and their dependencies. + /// + /// Collects: + /// - argument variables from the `arguments` node + /// - variables assigned via `set` statements in the core body + /// + /// Returns a list of `(variable, definition_range, var_dependencies)`. + /// Visibility (public vs private) is inferred from ProcedureBlock and `public_variables_declared`. + pub fn build_method_variable_defs( + &self, + node: Node, + content: &str, + ) -> Vec<(Variable, Range, Vec)> { + let mut variables: Vec<(Variable, Range, Vec)> = Vec::new(); + let children = get_node_children(node.clone()); + for node in children.iter().skip(1) { + if node.kind() == "arguments" { + let children = get_node_children(node.clone()); + for node in children { + // each node is an argument + let argument_children = get_node_children(node); + + let (variable_name, var_name_range, arg_type) = { + let mut name = None; + let mut var_range = None; + let mut arg_type = None; + for arg_child in argument_children { + if arg_child.kind() == "method_arg" { + if let Some(method_arg_type) = arg_child.named_child(0) { + let Some(variable_name_node) = method_arg_type.named_child(0) + else { + eprintln!( + "Error: Expression,byref_arg, and variadic_arg nodes all have a child at index 0, but this does not {:?}", + method_arg_type.kind() + ); + break; + }; + name = get_string_at_byte_range( + content, + variable_name_node.byte_range(), + ); + var_range = Some(variable_name_node.range()); + } else { + eprintln!( + "Error: Method arg node should have named children. This node didn't {:?}", + arg_child.kind() + ); + break; + } + } else if arg_child.kind() == "return_type" { + let Some(typename) = arg_child.named_child(1) else { + eprintln!( + "Error: expected return_type node to have typename at index 1" + ); + continue; + }; + if let Some(return_type_str) = + get_string_at_byte_range(content, typename.byte_range()) + && typename.kind() == "typename" + { + arg_type = Some(find_return_type(return_type_str)); + } else { + eprintln!( + "Error: expected return_type node to have typename at index 1" + ); + continue; + } + } + } + (name, var_range, arg_type) + }; + let Some(var_name) = variable_name else { + continue; + }; + let Some(var_name_range) = var_name_range else { + continue; + }; + if self.is_procedure_block.unwrap_or(true) == false + || self.public_variables_declared.contains(&var_name) + { + let var = Variable::new(var_name, arg_type, true, false, None); + variables.push((var, var_name_range, Vec::new())); + } else { + let var = Variable::new(var_name, arg_type, false, false, None); + variables.push((var, var_name_range, Vec::new())); + } + } + } else if node.kind() == "statement" { + let Some(command) = node.named_child(0) else { + eprintln!("Error: failed to get child node (index 0) from statement node"); + continue; + }; + match command.kind() { + "command_set" => { + let Some(set_argument) = command.named_child(1) else { + eprintln!( + "Error: failed to get child node (index 1) from command_set node" + ); + continue; + }; + let mut var_defs = Vec::new(); + let set_argument_children = get_node_children(set_argument); + for set_arg_child in set_argument_children { + match set_arg_child.kind() { + "set_target" => { + let Some(set_target_child) = set_arg_child.named_child(0) + else { + eprintln!( + "Error: Expected child at index 0 for set_target node {:?}", + set_arg_child.kind() + ); + continue; + }; + let var_range = set_target_child.range(); + match set_target_child.kind() { + "gvn" => { + let gvn_children = get_node_children(set_target_child); + for gvn_child in gvn_children { + if gvn_child.kind() == "identifier" { + if let Some(gvn_id) = get_string_at_byte_range( + content, + gvn_child.byte_range(), + ) { + var_defs.push((gvn_id, var_range)); + } + } + } + } + "lvn" => { + let Some(lvn_id_node) = set_target_child.named_child(0) + else { + eprintln!( + "Parsing Error: lvn must have a child at index 0, update parsing" + ); + continue; + }; + if let Some(lvn_id) = get_string_at_byte_range( + content, + lvn_id_node.byte_range(), + ) { + var_defs.push((lvn_id, var_range)); + } + } + _ => { + eprintln!( + "Warning: set target case: {:?} not yet implemented, skipping.", + set_target_child.kind() + ); + } + } + } + "expression" => { + let mut var_deps = Vec::new(); + let (is_oref, curr_class) = find_var_dependencies( + set_arg_child, + content, + &mut var_deps, + ); + for (var_name, v_range) in &var_defs { + let is_public = self.is_procedure_block.unwrap_or(true) + == false + || self.public_variables_declared.contains(var_name); + let variable = Variable::new( + var_name.clone(), + None, + is_public, + is_oref, + curr_class.clone(), + ); + variables.push((variable, *v_range, var_deps.clone())); + } + } + _ => continue, + } + } + } + _ => { + continue; + } + } + } + } + variables + } + + /// Applies inherited class keywords to this method when not explicitly set. + /// + /// - Inherits `ProcedureBlock=false` only when the method has no explicit setting. + /// - Inherits the class `default_language` when the method language is unset. + pub fn update_keywords(&mut self, is_procedure_block: bool, default_language: Language) { + // inherit class keywords if not specified and class keyword isn't the default value + if self.is_procedure_block.is_none() && is_procedure_block == false { + // inherit the class keyword when it isn't the default + self.is_procedure_block = Some(is_procedure_block); + } + + if self.language.is_none() { + // inherit the class keyword when it isn't the default + self.language = Some(default_language.clone()); + } + } +} diff --git a/objectscript-lsp/crates/objectscript-core/src/override_index.rs b/objectscript-lsp/crates/objectscript-core/src/override_index.rs new file mode 100644 index 0000000..547042e --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/override_index.rs @@ -0,0 +1,42 @@ +use crate::parse_structures::{ClassId, MethodRef}; +use std::collections::HashMap; + +/// Stores information about what superclass methods get overwritten, and by which subclass. +/// Stores the public methods available for each class. +/// For completion / resolution, this must be built after inheritance + overrides +#[derive(Default, Debug)] +pub struct OverrideIndex { + /// Stores the Method Id that a class sees for each public method name + pub effective_public_methods: HashMap>, + + /// subclass method ref (the method that overwites the superclass one) -> superclass method ref + pub overrides: HashMap, + + /// superclass method ref -> subclass method refs (subclass methods that overwrote the superclass) + pub overridden_by: HashMap>, +} + +impl OverrideIndex { + /// Creates an empty `OverrideIndex` with all maps initialized. + /// + /// This index is typically populated after computing inheritance and resolving overrides. + pub fn new() -> Self { + Self { + effective_public_methods: HashMap::new(), + overrides: HashMap::new(), + overridden_by: HashMap::new(), + } + } + + /// Returns a deep clone of the override index. + /// + /// Clones all internal maps (`effective_public_methods`, `overrides`, `overridden_by`). + /// Note: this duplicates `Clone` behavior; consider deriving `Clone` on `OverrideIndex` instead. + pub fn clone(&self) -> OverrideIndex { + Self { + effective_public_methods: self.effective_public_methods.clone(), + overrides: self.overrides.clone(), + overridden_by: self.overridden_by.clone(), + } + } +} diff --git a/objectscript-lsp/crates/objectscript-core/src/parse_structures.rs b/objectscript-lsp/crates/objectscript-core/src/parse_structures.rs new file mode 100644 index 0000000..edf6f45 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/parse_structures.rs @@ -0,0 +1,312 @@ +use std::collections::HashMap; +use std::hash::Hash; +use std::hash::Hasher; +use tree_sitter::Range; + +/// Stores the Index into `GlobalSemanticModel::classes`. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub struct ClassId(pub usize); + +/// Stores the Method Index, which is assigned by `class.next_id()`. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub struct MethodId(pub usize); + +/// Stores the Index into the per-class public variable vec in `GlobalSemanticModel::variables::ClassId`, where ClassId represents the class the variable is defined in. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub struct PublicVarId(pub usize); + +/// Stores the Index into `LocalSemanticModel::variables`. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub struct PrivateVarId(pub usize); + +/// Stores the index into the per-class property vec in `Class`. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub struct PropertyId(pub usize); + +/// Stores the index into the per-class parameter vec in `Class`. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub struct ParameterId(pub usize); + +/// Key used to identify a method by type and name (and later, signature). +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct MethodKey { + /// Class method or instance method. + pub method_type: MethodType, + /// Method name. + pub name: String, + // later: add signature info (arg count/types) to be correct for overloads +} + +/// Differentiates the kind of class member an identifier node represents. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MemberType { + Class, + ClassDef, + Relationship, + Foreignkey, + Parameter, + Projection, + Index, + Xdata, + Storage, + ClassMethodCall, + RelativeMethodCall, + Query, + Trigger, + Property, + OrefMethod, + RoutineMethodCall, + Routine, + LocalVariable, + SystemMember, + GlobalVariable, + MethodDef, +} + +/// DFS visitation state. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum DfsState { + Unvisited, + Visiting, + Done, +} + +/// Reference to a method implementation in a class (public or private). +/// +/// Exactly one of `pub_id` or `priv_id` is expected to be `Some`, depending on visibility/type. +#[derive(Copy, Clone, Debug)] +pub struct MethodRef { + pub class: ClassId, + pub id: MethodId, + pub offset: Option, +} + +impl PartialEq for MethodRef { + fn eq(&self, other: &Self) -> bool { + self.class == other.class && self.id == other.id + // offset intentionally ignored + } +} + +impl Eq for MethodRef {} + +impl Hash for MethodRef { + fn hash(&self, state: &mut H) { + self.class.hash(state); + self.id.hash(state); + + // offset intentionally ignored + } +} +// TODO: UNIMPLEMENTED: foreignkey, relationships, storage, query, index, trigger, xdata, projection +/// Semantic representation of a parsed ObjectScript class. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Class { + /// Class Name. + pub name: String, + /// Imported classes referenced by this class. + pub imports: Vec, // list of class names + // format: Include (macro file name) ex: include hannah for macro file hannah.inc + // pub include: Vec, // include files are inherited by subclasses, include files bring in macros at compile time + // pub include_gen: Vec, // this specifies include files to be generated + // if inheritance keyword == left, leftmost supersedes all (default) + // if inheritancedirection == right, right supersedes + /// Direct parent classes in the `Extends` list. + pub inherited_classes: Vec, + /// Inheritance conflict resolution direction (`left`, or `right`, default is `left`). + pub inheritance_direction: String, + /// Optional ProcedureBlock default for this class; If defined, methods will inherit this keyword if they don't specify it themselves. + pub is_procedure_block: Option, + /// Optional default Language keyword for this class. + pub default_language: Option, + /// Stores method name -> MethodRef for each method in this class. + pub methods: HashMap, + /// Stores property name -> id for each private property in this class. + pub private_properties: HashMap, + /// Stores property name -> id for each public property in this class. + pub public_properties: HashMap, + /// Stores parameter name -> id for each parameter in this class. + pub parameters: HashMap, + /// Whether this class entry is considered live/usable (e.g., false after removal). + pub active: bool, + /// Whether this representation is of a routine. + pub is_rtn: bool, + pub(crate) next_method_id: usize, +} + +/// Language keyword values supported for classes/methods. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Language { + Objectscript, + TSql, + Python, + ISpl, +} + +/// Semantic representation of a class property declaration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClassProperty { + pub name: String, + pub property_type: Option, + pub is_public: bool, + pub range: Range, +} + +/// Semantic representation of a class parameter declaration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClassParameter { + pub name: String, + pub property_type: Option, + pub default_argument_value: Option, // this can be a numeric literal, string literal, or identifier + pub range: Range, +} + +/// Distinguishes instance methods from class methods. +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub enum MethodType { + InstanceMethod, + ClassMethod, + Procedure, + Subroutine, + Routine, +} + +/// Reference linking a variable to its public and/or private identifier. +#[derive(Clone, Debug, Eq, PartialEq, Copy)] +pub struct VariableRef { + pub pub_id: Option, + pub priv_id: Option, +} + +/// Semantic Representation of an ObjectScript Method. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Method { + /// Class Method or Instance Method. + pub method_type: MethodType, + /// Expected return type. + pub return_type: Option, + /// Method Name. + pub name: String, + /// Stores variable name -> VariableRef for all variable definitions in this method. + pub variables: HashMap>, + /// Whether method is public or not. + pub is_public: bool, + /// Whether method is a procedure block or not. If None, method defaults to procedure block. + pub is_procedure_block: Option, + /// Stores language of method. If None, method defaults to ObjectScript. + pub language: Option, + /// Stores CodeMode of method. If None, method defaults to Code. + pub code_mode: CodeMode, + /// Names declared in `PublicList(...)` of ProcedureBlocks. + pub public_variables_declared: Vec, +} + +/// CodeMode keyword values supported for methods. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CodeMode { + Call, + Code, + Expression, + ObjectGenerator, +} + +/// Parsed representation of a class method call expression (syntactic/semantic summary). +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClassMethodCall { + pub name: String, + pub class_name: String, + pub method_name: String, + pub is_public: bool, +} + +/// Semantic representation of a variable discovered in a method. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Variable { + /// Variable name. + pub name: String, + /// Optional type of the argument if the variable originated from a method argument. + pub arg_type: Option, + /// Whether variable is public or not. + pub is_public: bool, + /// True if variable is an instance of a class, false otherwise. + pub is_oref: bool, + /// None if not an oref. If an oref, String representing class it points to. + pub cls: Option, +} + +/// Normalized return/type categories recognized. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ReturnType { + String, + Integer, + TinyInteger, // has diff max and min values + Number, + Binary, + Decimal, + Boolean, + Date, + Status, + TimeStamp, + DynamicObject, + DynamicArray, + Float, + Double, + HttpResponse, + Other(String), + SqlQuery, +} + +/// File type for a workspace document. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum FileType { + Cls, + Routine, + Xml, +} + +/// Parsed representation of a legacy statement targeted for refactoring. +#[derive(Clone, Debug)] +pub struct OldStatement { + pub last_expression_end_byte: Option, + pub last_expression_end_point: Option, + pub statement_ranges: Vec>, + pub keyword_old_range: tree_sitter::Range, + pub command_range: tree_sitter::Range, + pub comment_range: Option, + pub comment_after_last_statement_range: Option, + pub statements_after: Vec>, +} + +/// A routine block generated during refactoring to hold extracted code. +#[derive(Clone, Debug)] +pub struct GeneratedRoutineBlock { + pub name: String, + pub text: String, + pub insert_at: usize, +} + +/// A single refactoring operation pairing a text replacement with a generated routine block. +#[derive(Clone, Debug)] +pub struct RefactorStep { + pub replace_range: std::ops::Range, + pub replacement: String, + pub generated_block: GeneratedRoutineBlock, +} + +/// Controls which statement types are included in a refactoring pass. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RefactorLevel { + All, + DoCommands, + Conditionals, + ForCommands, +} + +/// Categorizes a statement by its control-flow construct type. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StatementType { + For, + If, + Conditionals, + Else, +} diff --git a/objectscript-lsp/crates/objectscript-core/src/refactor.rs b/objectscript-lsp/crates/objectscript-core/src/refactor.rs new file mode 100644 index 0000000..7447a4d --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/refactor.rs @@ -0,0 +1,1687 @@ +use crate::common::{ + advance_point, detect_newline, get_node_children, get_string_at_byte_range, + indent_statement_lines, line_indent_before, normalized_statement_lines, +}; +use crate::parse_structures::{FileType, OldStatement}; +use std::ops::Range; +use tree_sitter::{InputEdit, Language, Node, Parser, Query, QueryCursor, StreamingIterator}; +use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; +use tree_sitter_objectscript_routine::LANGUAGE_OBJECTSCRIPT_ROUTINE; +fn update_tree_and_content( + tree: &mut tree_sitter::Tree, + content: &mut String, + old_range: tree_sitter::Range, + replacement: &str, +) { + let (start_byte, start_point, old_end_byte, old_end_point) = ( + old_range.start_byte, + old_range.start_point, + old_range.end_byte, + old_range.end_point, + ); + let new_end_byte = start_byte + replacement.len(); + let new_end_position = advance_point(start_point.row, start_point.column, replacement); + let input_edit = InputEdit { + start_byte, + old_end_byte, + new_end_byte, + start_position: start_point, + old_end_position: old_end_point, + new_end_position, + }; + content.replace_range(start_byte..old_end_byte, replacement); + tree.edit(&input_edit); +} + +/// Creates a tree-sitter Parser configured for the given language grammar. +pub fn create_parser(language: &Language) -> Option { + let mut parser = Parser::new(); + parser.set_language(&language).ok()?; + Some(parser) +} + +fn remove_unreachable_statements( + content: &mut String, + tree: &mut tree_sitter::Tree, + language: &Language, + query_str: &str, + parser: &mut Parser, +) { + if let Ok(query) = Query::new(language, query_str) { + let root = tree.root_node(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(&query, root, content.as_bytes()); + let mut ranges = Vec::new(); + while let Some(m) = iter.next() { + if m.captures.len() == 1 { + ranges.push(m.captures[0].node.range()); + } + } + ranges.sort_by_key(|range| std::cmp::Reverse(range.start_byte)); + + for range in ranges { + update_tree_and_content(tree, content, range, ""); + let new_tree = parser.parse(content.as_str(), Some(&*tree)).unwrap(); + *tree = new_tree; + } + }; +} + +fn add_comment_to_string( + statement_struct: &OldStatement, + updated_string: &mut String, + replacement_string: &mut String, + after: bool, +) { + let comment_range; + if after { + comment_range = statement_struct.comment_after_last_statement_range; + } else { + comment_range = statement_struct.comment_range; + } + if let Some(comment_range) = comment_range { + let Some(comment) = get_string_at_byte_range( + updated_string, + Range { + start: comment_range.start_byte, + end: comment_range.end_byte, + }, + ) else { + eprintln!("Failed to get string of comment for statement"); + return; + }; + replacement_string.push_str(comment.as_str()); + } +} + +// in classes, for each method, store the conditionals +// in routines, for each file, store the conditionals +fn remove_unreachable_conditionals( + content: &str, + language: &Language, + parser: &mut Parser, +) -> Option<(tree_sitter::Tree, String)> { + // first remove if and else statements that are pointless (if statements with no expression and no statement) + // first, refactor the if-else statements + let mut updated_string = content.to_string(); + let Some(mut tree) = parser.parse(content, None) else { + eprint!("Failed to parse tree"); + return None; + }; + let query_str = "(command_if (keyword_old_if) (expression)? @condition (statement)? @statement) @command_if"; + remove_unreachable_statements(&mut updated_string, &mut tree, language, query_str, parser); + + let query_str = "(command_else (keyword_oldelse) (statement)? @statement) @command"; + remove_unreachable_statements(&mut updated_string, &mut tree, language, query_str, parser); + + // first remove unreachable if statements + Some((tree, updated_string)) +} + +fn remove_unreachable_for_statements( + content: &str, + language: &Language, + parser: &mut Parser, +) -> Option<(tree_sitter::Tree, String)> { + let mut updated_string = content.to_string(); + let Some(mut tree) = parser.parse(content, None) else { + eprint!("Failed to parse tree"); + return None; + }; + let query_str = + "(command_for (keyword_for) (for_parameter)? @param (statement)? @statement ) @command"; + remove_unreachable_statements(&mut updated_string, &mut tree, language, query_str, parser); + + let query_str = + "(command_for (keyword_old_for) (for_parameter)? @param (statement)? @statement ) @command"; + remove_unreachable_statements(&mut updated_string, &mut tree, language, query_str, parser); + Some((tree, updated_string)) +} + +fn refactor_if_else_statements( + tree: &mut tree_sitter::Tree, + updated_string: &mut String, + language: &Language, + query_str: &str, +) -> bool { + if let Ok(query) = Query::new(language, query_str) { + let root = tree.root_node(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(&query, root, updated_string.as_bytes()); + let Some(query_match) = iter.next() else { + return false; + }; + let if_statement = query_match.captures[0].node; + let else_statement = query_match.captures[1].node; + let Some(if_statement_struct) = build_old_statement_struct(&if_statement, &updated_string) + else { + eprintln!("Failed to build if_statement_struct"); + return false; + }; + let Some(else_statement_struct) = + build_old_statement_struct(&else_statement, &updated_string) + else { + eprintln!("Failed to build if_statement_struct"); + return false; + }; + let newline = detect_newline(updated_string); + let start_byte; + let start_point; + let (end_byte, end_point, else_has_comment, else_has_comment_after_last_statement) = + check_statement_fields(&else_statement_struct); + let if_has_comment = if_statement_struct.comment_range.is_some(); + let if_has_comment_after_last_statement = if_statement_struct + .comment_after_last_statement_range + .is_some(); + let mut replacement_string: String = String::new(); + let Some(else_statements) = normalized_statement_lines( + updated_string, + else_statement_struct.statement_ranges.as_slice(), + ) else { + return false; + }; + if if_statement_struct.last_expression_end_byte.is_none() { + start_byte = if_statement_struct.keyword_old_range.end_byte; + start_point = if_statement_struct.keyword_old_range.end_point; + // we know there are statements in this case, because + // otherwise it would have been handled by remove_unreachable_statements + replacement_string = String::from(" $TEST"); + } else { + if if_statement_struct.statement_ranges.is_empty() { + start_byte = if_statement_struct.command_range.start_byte; + start_point = if_statement_struct.command_range.start_point; + let base_indent = line_indent_before(updated_string, start_byte); + let Some(expression) = get_string_at_byte_range( + updated_string, + Range { + start: if_statement_struct.keyword_old_range.end_byte + 1, + end: if_statement_struct.last_expression_end_byte.unwrap(), + }, + ) else { + eprintln!("Failed to get string of expression for if statement"); + return false; + }; + replacement_string = format!("{base_indent}if '({expression})"); + if else_has_comment { + add_comment_to_string( + &else_statement_struct, + updated_string, + &mut replacement_string, + false, + ); + } + replacement_string.push_str( + build_replacement_string_block( + base_indent.as_str(), + newline, + else_statements.as_slice(), + ) + .as_str(), + ); + + if else_has_comment_after_last_statement { + add_comment_to_string( + &else_statement_struct, + updated_string, + &mut replacement_string, + true, + ); + } + let old_text = &updated_string[start_byte..end_byte]; + if old_text == replacement_string { + return false; + } + let range = tree_sitter::Range { + start_byte, + end_byte, + start_point, + end_point, + }; + update_tree_and_content(tree, updated_string, range, replacement_string.as_str()); + return true; + } else { + start_byte = if_statement_struct.last_expression_end_byte.unwrap(); + start_point = if_statement_struct.last_expression_end_point.unwrap(); + } + } + let base_indent = line_indent_before(updated_string, start_byte); + let Some(if_statements) = normalized_statement_lines( + updated_string, + if_statement_struct.statement_ranges.as_slice(), + ) else { + eprintln!("Failed to normalize if statement ranges"); + return false; + }; + if if_has_comment { + add_comment_to_string( + &if_statement_struct, + updated_string, + &mut replacement_string, + false, + ); + } + replacement_string.push_str( + build_replacement_string_block(base_indent.as_str(), newline, if_statements.as_slice()) + .as_str(), + ); + if if_has_comment_after_last_statement { + add_comment_to_string( + &if_statement_struct, + updated_string, + &mut replacement_string, + true, + ); + } + replacement_string.push_str(format!("{base_indent}else").as_str()); + if else_has_comment { + add_comment_to_string( + &else_statement_struct, + updated_string, + &mut replacement_string, + false, + ); + } + replacement_string.push_str( + build_replacement_string_block( + base_indent.as_str(), + newline, + else_statements.as_slice(), + ) + .as_str(), + ); + + if else_has_comment_after_last_statement { + add_comment_to_string( + &else_statement_struct, + updated_string, + &mut replacement_string, + true, + ); + } + + let old_text = &updated_string[start_byte..end_byte]; + if old_text == replacement_string { + return false; + } + let range = tree_sitter::Range { + start_byte, + end_byte, + start_point, + end_point, + }; + update_tree_and_content(tree, updated_string, range, replacement_string.as_str()); + return true; + } else { + return false; + } +} + +fn refactor_old_for_statements( + tree: &mut tree_sitter::Tree, + updated_string: &mut String, + language: &Language, + query_str: &str, +) -> bool { + if let Ok(query) = Query::new(language, query_str) { + let root = tree.root_node(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(&query, root, updated_string.as_bytes()); + let Some(query_match) = iter.next() else { + // everything has been refactored + return false; + }; + let for_statement = query_match.captures[0].node; + let Some(statement_struct) = build_old_statement_struct(&for_statement, &updated_string) + else { + eprintln!("Failed to build for statement struct"); + return false; + }; + let newline = detect_newline(updated_string); + let start_byte; + let start_point; + let (end_byte, end_point, has_comment, has_comment_after_last_statement) = + check_statement_fields(&statement_struct); + let mut replacement_string: String = String::new(); + if statement_struct.last_expression_end_byte.is_none() { + start_byte = statement_struct.keyword_old_range.end_byte; + start_point = statement_struct.keyword_old_range.end_point; + } else { + start_byte = statement_struct.last_expression_end_byte.unwrap(); + start_point = statement_struct.last_expression_end_point.unwrap(); + } + let base_indent = line_indent_before(updated_string, start_byte); + let Some(if_statements) = normalized_statement_lines( + updated_string, + statement_struct.statement_ranges.as_slice(), + ) else { + eprintln!("Failed to normalize if statement ranges"); + return false; + }; + if has_comment { + add_comment_to_string( + &statement_struct, + updated_string, + &mut replacement_string, + false, + ); + } + replacement_string.push_str( + build_replacement_string_block(base_indent.as_str(), newline, if_statements.as_slice()) + .as_str(), + ); + + if has_comment_after_last_statement { + add_comment_to_string( + &statement_struct, + updated_string, + &mut replacement_string, + true, + ); + } + + let old_text = &updated_string[start_byte..end_byte]; + if old_text == replacement_string { + return false; + } + let range = tree_sitter::Range { + start_byte, + end_byte, + start_point, + end_point, + }; + update_tree_and_content(tree, updated_string, range, replacement_string.as_str()); + return true; + } else { + return false; + } +} + +fn refactor_old_if_statements( + tree: &mut tree_sitter::Tree, + updated_string: &mut String, + language: &Language, + query_str: &str, +) -> bool { + if let Ok(query) = Query::new(language, query_str) { + let root = tree.root_node(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(&query, root, updated_string.as_bytes()); + let Some(query_match) = iter.next() else { + // everything has been refactored + return false; + }; + let if_statement = query_match.captures[0].node; + let Some(statement_struct) = build_old_statement_struct(&if_statement, &updated_string) + else { + eprintln!("Failed to build if_statement_struct"); + return false; + }; + let newline = detect_newline(updated_string); + let start_byte; + let start_point; + let (end_byte, end_point, has_comment, has_comment_after_last_statement) = + check_statement_fields(&statement_struct); + let mut replacement_string: String = String::new(); + if statement_struct.last_expression_end_byte.is_none() { + start_byte = statement_struct.keyword_old_range.end_byte; + start_point = statement_struct.keyword_old_range.end_point; + // we know there are statements in this case, because + // otherwise it would have been handled by remove_unreachable_statements + replacement_string = String::from(" $TEST"); + } else { + if statement_struct.statement_ranges.is_empty() { + let range = statement_struct.command_range; + update_tree_and_content(tree, updated_string, range, ""); + return true; + } else { + start_byte = statement_struct.last_expression_end_byte.unwrap(); + start_point = statement_struct.last_expression_end_point.unwrap(); + } + } + let base_indent = line_indent_before(updated_string, start_byte); + let Some(if_statements) = normalized_statement_lines( + updated_string, + statement_struct.statement_ranges.as_slice(), + ) else { + eprintln!("Failed to normalize if statement ranges"); + return false; + }; + if has_comment { + add_comment_to_string( + &statement_struct, + updated_string, + &mut replacement_string, + false, + ); + } + replacement_string.push_str( + build_replacement_string_block(base_indent.as_str(), newline, if_statements.as_slice()) + .as_str(), + ); + + if has_comment_after_last_statement { + add_comment_to_string( + &statement_struct, + updated_string, + &mut replacement_string, + true, + ); + } + + let old_text = &updated_string[start_byte..end_byte]; + if old_text == replacement_string { + return false; + } + let range = tree_sitter::Range { + start_byte, + end_byte, + start_point, + end_point, + }; + update_tree_and_content(tree, updated_string, range, replacement_string.as_str()); + return true; + } else { + return false; + } +} + +/// Extracts end byte/point and comment presence flags from an OldStatement. +pub fn check_statement_fields( + statement_struct: &OldStatement, +) -> (usize, tree_sitter::Point, bool, bool) { + let end_byte = statement_struct.command_range.end_byte; + let end_point = statement_struct.command_range.end_point; + let has_comment = statement_struct.comment_range.is_some(); + let has_comment_after_last_statement = statement_struct + .comment_after_last_statement_range + .is_some(); + ( + end_byte, + end_point, + has_comment, + has_comment_after_last_statement, + ) +} + +fn refactor_old_else_statements( + tree: &mut tree_sitter::Tree, + updated_string: &mut String, + language: &Language, + query_str: &str, +) -> bool { + if let Ok(query) = Query::new(language, query_str) { + let root = tree.root_node(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(&query, root, updated_string.as_bytes()); + let Some(query_match) = iter.next() else { + // everything has been refactored + return false; + }; + let else_statement = query_match.captures[0].node; + let Some(statement_struct) = build_old_statement_struct(&else_statement, &updated_string) + else { + eprintln!("Failed to build else_statement_struct"); + return false; + }; + let (end_byte, end_point, has_comment, has_comment_after_last_statement) = + check_statement_fields(&statement_struct); + let newline = detect_newline(updated_string); + let start_byte = statement_struct.keyword_old_range.start_byte; + let start_point = statement_struct.keyword_old_range.start_point; + let base_indent = line_indent_before(updated_string, start_byte); + let mut replacement_string = String::from(format!("{base_indent}if $TEST = 0")); + let Some(statements) = normalized_statement_lines( + updated_string, + statement_struct.statement_ranges.as_slice(), + ) else { + eprintln!("Failed to normalize if statement ranges"); + return false; + }; + if has_comment { + add_comment_to_string( + &statement_struct, + updated_string, + &mut replacement_string, + false, + ); + } + replacement_string.push_str( + build_replacement_string_block(base_indent.as_str(), newline, statements.as_slice()) + .as_str(), + ); + + if has_comment_after_last_statement { + add_comment_to_string( + &statement_struct, + updated_string, + &mut replacement_string, + true, + ); + } + let old_text = &updated_string[start_byte..end_byte]; + if old_text == replacement_string { + return false; + } + let range = tree_sitter::Range { + start_byte, + end_byte, + start_point, + end_point, + }; + update_tree_and_content(tree, updated_string, range, replacement_string.as_str()); + return true; + } else { + return false; + } +} + +/// Parses a legacy command node into an OldStatement capturing its keyword, expressions, statements, and comments. +pub fn build_old_statement_struct(node: &Node, content: &str) -> Option { + let children = get_node_children(node.clone()); + let mut statement_ranges = Vec::new(); + let mut expression_end_byte = None; + let mut expression_end_point = None; + let mut comment_range = None; + let command_range = node.range(); + let mut keyword_range: Option = None; + let mut saw_statement_comment = false; + let mut comment_after_statement_range = None; + // in the case where there's a comment right after, + // we want to store the statement before the comment + let mut last_statement_range_stored = None; + let mut statements_after_do = Vec::new(); + let depth = find_do_statement_depth(node); + + for child in children { + match child.kind() { + "for_parameter" => { + expression_end_byte = Some(child.end_byte()); + expression_end_point = Some(child.range().end_point); + } + "expression" => { + expression_end_byte = Some(child.end_byte()); + expression_end_point = Some(child.range().end_point); + } + "do_statement_after" => { + let range = std::ops::Range { + start: child.start_byte(), + end: child.end_byte(), + }; + statements_after_do.push(range); + } + "statement" => { + if saw_statement_comment { + saw_statement_comment = false; + last_statement_range_stored = None; + comment_after_statement_range = None; + } + let range = std::ops::Range { + start: child.start_byte(), + end: child.end_byte(), + }; + statement_ranges.push(range); + } + "dotted_statement" => { + if saw_statement_comment { + saw_statement_comment = false; + last_statement_range_stored = None; + comment_after_statement_range = None; + } + let Some(line) = get_string_at_byte_range(content, child.byte_range()) else { + continue; + }; + let dot_count = count_leading_dots_in_line(&line); + if dot_count >= depth { + let range = std::ops::Range { + start: child.start_byte(), + end: child.end_byte(), + }; + statement_ranges.push(range); + } + } + "argumentless_inline_comment" => { + comment_range = Some(child.range()); + } + "inline_comment" => { + if !statements_after_do.is_empty() && statement_ranges.is_empty() { + // this is between statements, so it should be after the last statement end + let Some(last_statement_range) = statements_after_do.pop() else { + eprintln!("Failed to get last statement range"); + continue; + }; + let new_range = Range { + start: last_statement_range.start, + end: child.end_byte(), + }; + statements_after_do.push(new_range); + continue; + } + if !statement_ranges.is_empty() { + saw_statement_comment = true; + // this is between statements, so it should be after the last statement end + let Some(last_statement_range) = statement_ranges.pop() else { + eprintln!("Failed to get last statement range"); + continue; + }; + last_statement_range_stored = Some(last_statement_range.clone()); + comment_after_statement_range = Some(child.range()); + let new_range = Range { + start: last_statement_range.start, + end: child.end_byte(), + }; + statement_ranges.push(new_range); + continue; + } else { + // this is between the expr and the start of the statements + // or in the case of for statements it can be + // between the keyword and expr or the expr and statements + if node.kind() == "command_for" && expression_end_byte.is_none() { + let new_keyword_range_end_byte = child.end_byte(); + let new_keyword_range_end_point = child.end_position(); + let Some(original_range) = keyword_range else { + eprintln!("Error: keyword range was none"); + continue; + }; + let new_range = tree_sitter::Range { + start_byte: original_range.start_byte, + start_point: original_range.start_point, + end_byte: new_keyword_range_end_byte, + end_point: new_keyword_range_end_point, + }; + keyword_range = Some(new_range); + continue; + } + comment_range = Some(child.range()); + } + } + kind if kind.starts_with("keyword_") => { + keyword_range = Some(child.range()); + } + _ => { + continue; + } + } + } + let Some(keyword_old_range) = keyword_range else { + eprintln!("Error: keyword_old_range is None"); + return None; + }; + + if saw_statement_comment { + statement_ranges.pop(); + let Some(new_range) = last_statement_range_stored else { + eprintln!("Last statement range was never stored"); + return None; + }; + statement_ranges.push(new_range.clone()); + } + + Some(OldStatement { + last_expression_end_byte: expression_end_byte, + last_expression_end_point: expression_end_point, + statement_ranges, + keyword_old_range, + command_range, + comment_range, + comment_after_last_statement_range: comment_after_statement_range, + statements_after: statements_after_do, + }) +} + +fn refactor_old_conditional_command( + tree: &mut tree_sitter::Tree, + updated_string: &mut String, + language: &Language, + parser: &mut Parser, +) { + // first refactor if-else statements + // RULES: + // 1. If the if statement is useless (has no other statements attached to it), + // then, the if-else will be replaced by a single if statement that is + // if '(expression) + // + // 2. argumentless if -> if $TEST + // 3. Both the if and else statements will be converted to their block form + // 4. Comments will be preserved. + let query_str = r#"(source_file + (statement (command_if (keyword_old_if)) @command_if) + . + (statement (command_else) @command_else) +)"#; + + loop { + let changed = refactor_if_else_statements(tree, updated_string, language, query_str); + let new_tree = parser.parse(updated_string.as_str(), Some(&*tree)).unwrap(); + *tree = new_tree; + + if !changed { + break; + } + } + eprintln!("Finished if-else refactoring"); + + let query_str = "(command_if (keyword_old_if)) @command"; + loop { + let changed = refactor_old_if_statements(tree, updated_string, language, query_str); + let new_tree = parser.parse(updated_string.as_str(), Some(&*tree)).unwrap(); + *tree = new_tree; + + if !changed { + break; + } + } + eprintln!("Finished old if refactoring"); + let query_str = "(command_else (keyword_oldelse)) @command_else"; + loop { + let changed = refactor_old_else_statements(tree, updated_string, language, query_str); + let new_tree = parser.parse(updated_string.as_str(), Some(&*tree)).unwrap(); + *tree = new_tree; + + if !changed { + break; + } + } + eprintln!("Finished old else refactoring"); +} + +fn build_replacement_string_block( + base_indent: &str, + newline: &str, + statements: &[String], +) -> String { + let statement_indent = format!("{base_indent} "); + let mut new_str = String::new(); + new_str.push_str(format!(" {{{newline}").as_str()); + new_str + .push_str(indent_statement_lines(statements, statement_indent.as_str(), newline).as_str()); + new_str.push_str(format!("{newline}{base_indent}}}").as_str()); + new_str +} + +fn refactor_legacy_for_statements( + tree: &mut tree_sitter::Tree, + updated_string: &mut String, + language: &Language, + parser: &mut Parser, +) { + // turn into block version + let query_str = "(command_for (keyword_old_for)) @command"; + loop { + let changed = refactor_old_for_statements(tree, updated_string, language, query_str); + let new_tree = parser.parse(updated_string.as_str(), Some(&*tree)).unwrap(); + *tree = new_tree; + + if !changed { + break; + } + } + eprintln!("Finished refactoring legacy for statements"); +} + +/// Refactors legacy `for` commands in ObjectScript source to block form. +pub fn refactor_for_statements(content: &str, file_type: FileType) -> String { + let language: Language; + if file_type == FileType::Cls { + language = LANGUAGE_OBJECTSCRIPT_UDL.into(); + } else { + language = LANGUAGE_OBJECTSCRIPT_ROUTINE.into() + } + let Some(mut parser) = create_parser(&language) else { + eprintln!("Error: Failed to create parser"); + return content.to_string(); + }; + + let Some((mut updated_tree, mut updated_string)) = + remove_unreachable_for_statements(content, &language, &mut parser) + else { + eprintln!("Failed to get tree and string from remove_unreachable_conditionals"); + return content.to_string(); + }; + refactor_legacy_for_statements( + &mut updated_tree, + &mut updated_string, + &language, + &mut parser, + ); + updated_string +} + +/// Refactors legacy `if`/`else` commands in ObjectScript source to block form. +pub fn refactor_conditionals(content: &str, file_type: FileType) -> String { + let language: Language; + if file_type == FileType::Cls { + language = LANGUAGE_OBJECTSCRIPT_UDL.into(); + } else { + language = LANGUAGE_OBJECTSCRIPT_ROUTINE.into() + } + let Some(mut parser) = create_parser(&language) else { + eprintln!("Error: Failed to create parser"); + return content.to_string(); + }; + // first remove if and else statements that are pointless (if statements with no expression and no statement) + let Some((mut updated_tree, mut updated_string)) = + remove_unreachable_conditionals(content, &language, &mut parser) + else { + eprintln!("Failed to get tree and string from remove_unreachable_conditionals"); + return content.to_string(); + }; + // then, refactor the legacy if-else statements, if statements, and else statements + refactor_old_conditional_command( + &mut updated_tree, + &mut updated_string, + &language, + &mut parser, + ); + updated_string +} + +fn routine_members(node: &Node, content: &str) -> Vec { + let mut names = Vec::new(); + let children = get_node_children(node.clone()); + for child in children { + match child.kind() { + "routine_definition" => { + let Some(routine_name) = child.named_child(1) else { + eprintln!("Error: couldn't get routine_definition child"); + continue; + }; + let Some(name) = get_string_at_byte_range(content, routine_name.byte_range()) + else { + eprintln!("Couldn't get routine name"); + continue; + }; + names.push(name.to_string()); + } + "statement" => { + let Some(command) = child.named_child(0) else { + eprintln!("Error: couldn't get statement child"); + return names; + }; + match command.kind() { + "tag_statement" | "procedure" => { + let Some(tag) = command.named_child(0) else { + eprintln!("Error: Couldn't get tag from tag statement/procedure"); + return names; + }; + let Some(tag_name) = get_string_at_byte_range(content, tag.byte_range()) + else { + eprintln!("Couldn't get tag name"); + return names; + }; + names.push(tag_name); + } + _ => continue, + } + } + "dotted_statement" => { + let Some(command) = child.named_child(0) else { + eprintln!("Error: couldn't get statement child"); + return names; + }; + if command.kind() == "tag" { + let Some(tag_name) = get_string_at_byte_range(content, command.byte_range()) + else { + eprintln!("Couldn't get tag name"); + return names; + }; + names.push(tag_name); + } + } + _ => continue, + } + } + names +} + +fn generated_subroutine_base_name(subroutine_name: &str) -> &str { + let Some((base_name, suffix)) = subroutine_name.rsplit_once("Subroutine") else { + return subroutine_name; + }; + if !base_name.is_empty() && suffix.chars().all(|ch| ch.is_ascii_digit()) { + base_name + } else { + subroutine_name + } +} + +fn generate_subroutine_name( + subroutine_name: &str, + mut dot_depth: usize, + routine_members: &mut Vec, +) -> String { + let subroutine_name = generated_subroutine_base_name(subroutine_name); + loop { + let candidate = format!("{subroutine_name}Subroutine{dot_depth}"); + + if !routine_members.contains(&candidate) { + routine_members.push(candidate.clone()); + return candidate; + } + dot_depth += 1; + } +} + +fn line_starts_routine_member(line: &str) -> bool { + if line.is_empty() || line.starts_with(' ') || line.starts_with('\t') { + return false; + } + + let Some(token) = line.split_whitespace().next() else { + return false; + }; + if token.eq_ignore_ascii_case("ROUTINE") || token.starts_with("#;") { + return false; + } + + let Some(first) = token.chars().next() else { + return false; + }; + first.is_ascii_alphabetic() || first == '%' || first == '$' +} + +fn has_routine_member_between(content: &str, start_byte: usize, end_byte: usize) -> bool { + let mut cursor = start_byte; + let end_byte = end_byte.min(content.len()); + while cursor < end_byte { + let tail = content.get(cursor..end_byte).unwrap_or(""); + let next_end = tail + .find('\n') + .map(|offset| cursor + offset + 1) + .unwrap_or(end_byte); + let line = content + .get(cursor..next_end) + .unwrap_or("") + .trim_end_matches('\n') + .trim_end_matches('\r'); + if line_starts_routine_member(line) { + return true; + } + cursor = next_end; + } + false +} + +fn line_is_comment_or_blank(line: &str) -> bool { + let trimmed = line.trim(); + trimmed.is_empty() + || trimmed.starts_with(';') + || trimmed.starts_with("#;") + || trimmed.starts_with("//") + || trimmed.starts_with("/*") +} + +fn byte_after_trailing_comments(content: &str, start_byte: usize) -> usize { + let mut cursor = start_byte.min(content.len()); + while cursor < content.len() { + let tail = content.get(cursor..).unwrap_or(""); + let line_end = tail + .find('\n') + .map(|offset| cursor + offset) + .unwrap_or(content.len()); + let line = content + .get(cursor..line_end) + .unwrap_or("") + .trim_end_matches('\r'); + if line_starts_routine_member(line) || !line_is_comment_or_blank(line) { + break; + } + cursor = if matches!(content.as_bytes().get(line_end), Some(b'\n')) { + line_end + 1 + } else { + line_end + }; + } + cursor +} + +/// Returns the number of whitespace bytes immediately before `byte_index` on its line. +pub fn whitespace_before_byte(content: &str, byte_index: usize) -> usize { + let line_start = content + .get(..byte_index) + .and_then(|prefix| prefix.rfind('\n').map(|index| index + 1)) + .unwrap_or(0); + + let prefix = content.get(line_start..byte_index).unwrap_or(""); + + prefix + .chars() + .rev() + .take_while(|ch| ch.is_whitespace() && *ch != '\n' && *ch != '\r') + .map(char::len_utf8) + .sum() +} + +/// Counts nested `command_do` ancestors to determine the dot depth for this do block. +pub fn find_do_statement_depth(node: &Node) -> usize { + let mut count = 1; // outer do has one dot for its statements + let mut parent = node.clone(); + while let Some(next) = parent.parent() { + if next.kind() == "command_do" { + count += 1; + } + parent = next; + } + count +} + +// given command_do node, gets string and checks if it has $TEST, JOB, LOCK, OPEN, or READ +// if so, returns true, otherwise false +fn changes_test_variable(content: &str, node: &Node) -> bool { + let Some(str) = get_string_at_byte_range(content, node.byte_range()) else { + eprintln!("Couldn't get string from node {:?}", node.kind()); + return false; + }; + let str = str.to_lowercase(); + str.contains("$test") + || str.contains("job") + || str.contains("lock") + || str.contains("open") + || str.contains("read") +} + +struct GeneratedDoSubroutine { + name: String, + text: String, +} + +fn node_has_child_kind(node: Node, kind: &str) -> bool { + let mut cursor = node.walk(); + let has_child = node + .named_children(&mut cursor) + .any(|child| child.kind() == kind); + has_child +} + +fn is_old_do_with_dotted_body(content: &str, node: Node) -> bool { + node.kind() == "command_do" + && node_has_child_kind(node, "keyword_do_old") + && direct_dotted_body_depth(content, node.range()).is_some() +} + +fn line_end_before_newline(content: &str, start_byte: usize, max_end_byte: usize) -> usize { + content + .get(start_byte..max_end_byte) + .and_then(|tail| tail.find('\n')) + .map(|offset| start_byte + offset) + .unwrap_or(max_end_byte) +} + +fn point_at_byte(content: &str, byte_index: usize) -> tree_sitter::Point { + let safe_byte_index = byte_index.min(content.len()); + let prefix = content.get(..safe_byte_index).unwrap_or(""); + let row = prefix.bytes().filter(|byte| *byte == b'\n').count(); + let line_start = prefix.rfind('\n').map(|index| index + 1).unwrap_or(0); + tree_sitter::Point { + row, + column: safe_byte_index - line_start, + } +} + +fn next_line_start(content: &str, start_byte: usize, max_end_byte: usize) -> Option { + content + .get(start_byte..max_end_byte) + .and_then(|tail| tail.find('\n')) + .map(|offset| start_byte + offset + 1) + .filter(|next| *next < max_end_byte) +} + +/// Counts the number of leading dots (ObjectScript block depth indicators) in a line. +pub fn count_leading_dots_in_line(line: &str) -> usize { + let bytes = line.as_bytes(); + let mut cursor = 0; + while matches!(bytes.get(cursor), Some(b' ' | b'\t')) { + cursor += 1; + } + + let mut count = 0; + loop { + if !matches!(bytes.get(cursor), Some(b'.')) { + break; + } + count += 1; + cursor += 1; + while matches!(bytes.get(cursor), Some(b' ' | b'\t')) { + cursor += 1; + } + } + count +} + +fn direct_dotted_body_depth(content: &str, range: tree_sitter::Range) -> Option { + let command_line_start = content + .get(..range.start_byte) + .and_then(|prefix| prefix.rfind('\n').map(|index| index + 1)) + .unwrap_or(0); + let command_line_end = line_end_before_newline(content, command_line_start, content.len()); + let command_line = content + .get(command_line_start..command_line_end) + .unwrap_or("") + .trim_end_matches('\r'); + let expected_depth = count_leading_dots_in_line(command_line) + 1; + let mut cursor = next_line_start(content, range.start_byte, range.end_byte)?; + while cursor < range.end_byte { + let line_end = line_end_before_newline(content, cursor, range.end_byte); + let line = content + .get(cursor..line_end) + .unwrap_or("") + .trim_end_matches('\r'); + if count_leading_dots_in_line(line) == expected_depth { + return Some(expected_depth); + } + cursor = match next_line_start(content, cursor, range.end_byte) { + Some(next) => next, + None => break, + }; + } + None +} + +fn dotted_body_line_ranges( + content: &str, + range: tree_sitter::Range, + dot_depth: usize, +) -> Vec> { + let mut ranges = Vec::new(); + let Some(mut cursor) = next_line_start(content, range.start_byte, range.end_byte) else { + return ranges; + }; + + while cursor < range.end_byte { + let line_end = line_end_before_newline(content, cursor, range.end_byte); + let line = content + .get(cursor..line_end) + .unwrap_or("") + .trim_end_matches('\r'); + if count_leading_dots_in_line(line) >= dot_depth { + ranges.push(cursor..line_end); + } + cursor = match next_line_start(content, cursor, range.end_byte) { + Some(next) => next, + None => break, + }; + } + + ranges +} + +fn dotted_body_replacement_end(content: &str, range: tree_sitter::Range) -> Option { + let dot_depth = direct_dotted_body_depth(content, range)?; + let body_line_ranges = dotted_body_line_ranges(content, range, dot_depth); + let last_body_end = body_line_ranges.last()?.end; + Some( + if matches!(content.as_bytes().get(last_body_end), Some(b'\n')) { + last_body_end + 1 + } else { + last_body_end + }, + ) +} + +fn strip_dotted_prefix(line: &str, dot_depth: usize) -> Option<(String, usize)> { + let bytes = line.as_bytes(); + let mut cursor = 0; + while matches!(bytes.get(cursor), Some(b' ' | b'\t')) { + cursor += 1; + } + + for index in 0..dot_depth { + if !matches!(bytes.get(cursor), Some(b'.')) { + return None; + } + cursor += 1; + if index + 1 < dot_depth { + while matches!(bytes.get(cursor), Some(b' ' | b'\t')) { + cursor += 1; + } + } else if matches!(bytes.get(cursor), Some(b' ' | b'\t')) { + cursor += 1; + } + } + + Some(( + line.get(cursor..) + .unwrap_or("") + .trim_end_matches('\r') + .to_string(), + cursor, + )) +} + +fn build_new_do_call(content: &str, node: Node, sub_name: &str) -> Option { + let mut new_do_call = format!("do {sub_name}"); + let Some(statement_struct) = build_old_statement_struct(&node, content) else { + eprintln!("Failed to build do command struct"); + return None; + }; + for range in statement_struct.statements_after { + let Some(statement) = get_string_at_byte_range(content, range) else { + eprintln!("Failed to get statement from do statements after"); + return None; + }; + new_do_call.push_str(format!(" {statement}").as_str()); + } + Some(new_do_call) +} + +fn line_starts_with_quit_or_return(line: &str) -> bool { + let trimmed = line.trim_start().to_ascii_lowercase(); + trimmed == "q" + || trimmed == "quit" + || trimmed == "return" + || trimmed.starts_with("q ") + || trimmed.starts_with("quit ") + || trimmed.starts_with("return ") +} + +fn normalize_generated_dotted_line(line: &str) -> String { + let trimmed = line.trim(); + let mut normalized = String::with_capacity(trimmed.len()); + + for ch in trimmed.chars() { + if ch == '{' { + while normalized.ends_with(' ') || normalized.ends_with('\t') { + normalized.pop(); + } + if !normalized.is_empty() { + normalized.push(' '); + } + } + normalized.push(ch); + } + + normalized +} + +fn leading_closing_braces(line: &str) -> usize { + line.chars().take_while(|ch| *ch == '}').count() +} + +fn brace_counts(line: &str) -> (usize, usize) { + line.chars().fold((0, 0), |(open, close), ch| match ch { + '{' => (open + 1, close), + '}' => (open, close + 1), + _ => (open, close), + }) +} + +fn build_generated_dotted_do( + content: &str, + command_do: Node, + outer_subroutine_name: &str, + routine_members: &mut Vec, + newline: &str, +) -> Option { + let dot_depth = direct_dotted_body_depth(content, command_do.range())?; + let sub_name = generate_subroutine_name(outer_subroutine_name, dot_depth, routine_members); + let body_line_ranges = dotted_body_line_ranges(content, command_do.range(), dot_depth); + if body_line_ranges.is_empty() { + eprintln!("There should be at least one dotted statement, found none"); + return None; + } + + let base_indent = " "; + let block_indent = " "; + let mut body = String::new(); + let mut quit_or_return_end = false; + let mut block_depth = 0usize; + + if changes_test_variable(content, &command_do) { + body.push_str(format!("{base_indent}set temp=$TEST{newline}").as_str()); + } + + for line_range in body_line_ranges { + let Some(raw_line) = content.get(line_range.clone()) else { + eprintln!("Error: couldn't get dotted statement string from range"); + return None; + }; + let Some((line, _)) = strip_dotted_prefix(raw_line, dot_depth) else { + eprintln!("Error: couldn't strip dotted statement prefix"); + return None; + }; + let line = normalize_generated_dotted_line(line.as_str()); + let indent_depth = block_depth.saturating_sub(leading_closing_braces(line.as_str())); + quit_or_return_end = line_starts_with_quit_or_return(line.as_str()); + body.push_str(base_indent); + for _ in 0..indent_depth { + body.push_str(block_indent); + } + body.push_str(line.as_str()); + body.push_str(newline); + + let (open_braces, close_braces) = brace_counts(line.as_str()); + block_depth = block_depth.saturating_add(open_braces); + block_depth = block_depth.saturating_sub(close_braces); + } + + if changes_test_variable(content, &command_do) { + body.push_str(format!("{base_indent}set $TEST=temp{newline}").as_str()); + } + if !quit_or_return_end { + body.push_str(format!("{base_indent}quit{newline}").as_str()); + } + + let text = format!("{newline}{sub_name}{newline}{body}"); + + Some(GeneratedDoSubroutine { + name: sub_name, + text, + }) +} + +fn refactor_smallest_dotted_do( + tree: &mut tree_sitter::Tree, + updated_string: &mut String, + language: &Language, + query_str: &str, + _parser: &mut Parser, + routine_members: &mut Vec, +) -> bool { + if let Ok(query) = Query::new(language, query_str) { + let mut nodes = Vec::new(); + let root = tree.root_node(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(&query, root, updated_string.as_bytes()); + while let Some(m) = iter.next() { + nodes.push(m.captures[0].node); + } + nodes.sort_by_key(|node| node.start_byte()); + if nodes.is_empty() { + return false; + } + let Some(command_do) = nodes + .into_iter() + .find(|node| is_old_do_with_dotted_body(updated_string.as_str(), *node)) + else { + return false; + }; + + let Some(associated_parent) = command_do.parent() else { + eprintln!("Couldn't get parent node for do command"); + return false; + }; + + let Some((outer_subroutine_name, sub_last_statement_range)) = + find_do_statement_subroutine(updated_string.as_str(), &associated_parent) + else { + eprintln!( + "Couldn't find do statement subroutine {:?}", + command_do.kind() + ); + return false; + }; + let newline = detect_newline(updated_string); + let Some(generated) = build_generated_dotted_do( + updated_string.as_str(), + command_do, + outer_subroutine_name.as_str(), + routine_members, + newline, + ) else { + return false; + }; + let Some(mut new_do_call) = + build_new_do_call(updated_string.as_str(), command_do, generated.name.as_str()) + else { + return false; + }; + + let mut old_do_range = command_do.range(); + if let Some(end_byte) = + dotted_body_replacement_end(updated_string.as_str(), command_do.range()) + { + if end_byte < old_do_range.end_byte { + old_do_range.end_byte = end_byte; + old_do_range.end_point = point_at_byte(updated_string.as_str(), end_byte); + } + } + let old_do_spans_lines = old_do_range.start_point.row != old_do_range.end_point.row; + let mut added_comment = false; + let Some(statement_struct) = + build_old_statement_struct(&command_do, updated_string.as_str()) + else { + eprintln!("Failed to build do command struct"); + return false; + }; + if let Some(comment_range) = statement_struct.comment_after_last_statement_range { + let Some(comment) = get_string_at_byte_range( + updated_string.as_str(), + comment_range.start_byte..comment_range.end_byte, + ) else { + eprintln!("Failed to get comment after dotted do"); + return false; + }; + new_do_call.push_str(newline); + new_do_call.push_str(comment.as_str()); + added_comment = true; + } else if let Some(comment_range) = statement_struct.comment_range { + let Some(comment) = get_string_at_byte_range( + updated_string.as_str(), + comment_range.start_byte..comment_range.end_byte, + ) else { + eprintln!("Failed to get comment for dotted do"); + return false; + }; + new_do_call.push_str(newline); + new_do_call.push_str(comment.as_str()); + added_comment = true; + } + if old_do_spans_lines || added_comment { + new_do_call.push_str(newline); + } + let insert_byte = if has_routine_member_between( + updated_string.as_str(), + old_do_range.end_byte, + sub_last_statement_range.end_byte, + ) { + byte_after_trailing_comments(updated_string.as_str(), old_do_range.end_byte) + } else { + byte_after_trailing_comments(updated_string.as_str(), sub_last_statement_range.end_byte) + }; + let insert_point = point_at_byte(updated_string.as_str(), insert_byte); + let insert_range = tree_sitter::Range { + start_byte: insert_byte, + end_byte: insert_byte, + start_point: insert_point, + end_point: insert_point, + }; + update_tree_and_content(tree, updated_string, insert_range, generated.text.as_str()); + update_tree_and_content(tree, updated_string, old_do_range, new_do_call.as_str()); + return true; + } else { + return false; + } +} + +/// Given a source_file node, parse the routine and update spacing for first level statements +fn refactor_spacing_for_subroutines(root: Node, content: &mut String) -> Option { + let base_indent = " "; + let mut replacement_string = String::new(); + let statement_children = get_node_children(root); + let newline = detect_newline(content); + for statement in statement_children { + match statement.kind() { + "line_comment_1" | "line_comment_2" | "line_comment_4" | "routine_definition" => { + let Some(statement_str) = + get_string_at_byte_range(content.as_str(), statement.byte_range()) + else { + eprintln!("Error: Failed to get statement string at byte range"); + return None; + }; + replacement_string.push_str(format!("{statement_str}{newline}").as_str()); + } + "block_comment" | "line_comment_3" => { + let Some(statement_str) = + get_string_at_byte_range(content.as_str(), statement.byte_range()) + else { + eprintln!("Error: Failed to get statement string at byte range"); + return None; + }; + replacement_string + .push_str(format!("{base_indent}{statement_str}{newline}").as_str()); + } + _ => { + let Some(command) = statement.named_child(0) else { + eprintln!("Error: Expected statement to have a child, but got None"); + return None; + }; + let Some(statement_str) = + get_string_at_byte_range(content.as_str(), statement.byte_range()) + else { + eprintln!("Error: Failed to get statement string at byte range"); + return None; + }; + if command.kind() == "tag_statement" || command.kind() == "procedure" { + replacement_string.push_str(newline); + let str = format!("{statement_str}{newline}"); + replacement_string.push_str(str.as_str()); + continue; + } + let trimmed = statement_str.trim_start(); + let new = format!("{base_indent}{trimmed}{newline}"); + replacement_string.push_str(new.as_str()); + } + } + } + Some(replacement_string) +} + +/// Extracts dotted `do` bodies into named subroutines and replaces them with `do subroutineName` calls. +pub fn refactor_legacy_do_statements(content: &str) -> String { + let language = LANGUAGE_OBJECTSCRIPT_ROUTINE.into(); + let Some(mut parser) = create_parser(&language) else { + eprintln!("Error: Failed to create parser"); + return content.to_string(); + }; + let Some(mut tree) = parser.parse(content, None) else { + eprintln!("Failed to parse content"); + return content.to_string(); + }; + let mut curr_routine_members = routine_members(&tree.root_node(), content); + + let mut updated_string = content.to_string(); + + let query_str = "(command_do (keyword_do_old)) @command"; + let mut at_least_one_change = false; + + loop { + let changed = refactor_smallest_dotted_do( + &mut tree, + &mut updated_string, + &language, + query_str, + &mut parser, + &mut curr_routine_members, + ); + let new_tree = parser.parse(updated_string.as_str(), Some(&tree)).unwrap(); + tree = new_tree; + if !changed { + break; + } + at_least_one_change = true; + } + if at_least_one_change { + if let Some(replacement_str) = + refactor_spacing_for_subroutines(tree.root_node(), &mut updated_string) + { + let range = tree.root_node().range(); + update_tree_and_content(&mut tree, &mut updated_string, range, &replacement_str); + }; + } + + updated_string +} + +/// given a statement node of a do statement, find the subroutine or procedure that the +/// node is a part of +pub fn find_do_statement_subroutine( + content: &str, + node: &Node, +) -> Option<(String, tree_sitter::Range)> { + let mut tracker = node.clone(); + + while let Some(next) = tracker.parent() { + if next.kind() == "procedure" || next.kind() == "source_file" { + break; + } + tracker = next; + } + let node = tracker; + if let Some(parent) = node.parent() { + if parent.kind() == "procedure" { + let Some(tag_with_params) = parent.named_child(0) else { + eprintln!("Expected procedure child to be tag_with_params, but it dne"); + return None; + }; + let Some(tag) = tag_with_params.named_child(0) else { + eprintln!("Expected tag_with_params child to be tag, but it dne"); + return None; + }; + let Some(name) = get_string_at_byte_range(content, tag.byte_range()) else { + eprintln!("Failed to get tag name"); + return None; + }; + + return Some((name, parent.range())); + } + } + let mut prev_sibling = node.prev_named_sibling(); + let name; + // now outside of this, we need to look for the closest tag statement + while let Some(statement) = prev_sibling { + let Some(command) = statement.named_child(0) else { + prev_sibling = statement.prev_named_sibling(); + continue; + }; + if command.kind() == "tag_statement" || command.kind() == "tag_with_params" { + let Some(tag) = command.named_child(0) else { + eprintln!("Couldn't get tag statement child"); + return None; + }; + let Some(tag_name) = get_string_at_byte_range(content, tag.byte_range()) else { + eprintln!("Failed to get tag name string"); + return None; + }; + name = tag_name; + + let mut next_sibling = node.next_named_sibling(); + let mut last_statement_before_end = None; + while let Some(next_statement) = next_sibling { + let Some(command) = next_statement.named_child(0) else { + next_sibling = next_statement.next_named_sibling(); + continue; + }; + if command.kind() == "tag_statement" + || command.kind() == "tag_with_params" + || command.kind() == "procedure" + { + let Some(last_statement_before_end) = next_statement.prev_named_sibling() + else { + eprintln!("Couldn't get last statement before end"); + return None; + }; + return Some((name, last_statement_before_end.range())); + } + last_statement_before_end = Some(next_statement); + next_sibling = next_statement.next_named_sibling(); + } + if let Some(last_statement_before_end) = last_statement_before_end { + let mut sibling = node.next_sibling(); + let mut last_node = None; + + while let Some(next) = sibling { + last_node = Some(next); + sibling = next.next_sibling(); + } + if let Some(last_node) = last_node { + return Some((name, last_node.range())); + } + return Some((name, last_statement_before_end.range())); + } else { + return Some((name, node.range())); + } + } + prev_sibling = statement.prev_named_sibling(); + continue; + } + return None; +} diff --git a/objectscript-lsp/crates/objectscript-core/src/scope_structures.rs b/objectscript-lsp/crates/objectscript-core/src/scope_structures.rs new file mode 100644 index 0000000..8dd1307 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/scope_structures.rs @@ -0,0 +1,72 @@ +use crate::parse_structures::MethodRef; +use tower_lsp::lsp_types::Url; +use tree_sitter::Range; + +/// The Key into `ScopeTree::scopes` representing a single `Scope`. +#[derive(Copy, Hash, Eq, PartialEq, Clone, Debug)] +pub struct ScopeId(pub usize); + +/// A variable definition symbol for a public variable (definition site + dependency metadata). +#[derive(Clone, Debug)] +pub struct VariableGlobalSymbol { + /// Document URl containing the variable definition. + pub url: Url, + /// Source range of the variable definition. + pub location: Range, + /// Names of other variables referenced by this definition. + pub var_dependencies: Vec, +} + +/// A method definition symbol for a public method. +#[derive(Clone, Debug)] +pub struct MethodGlobalSymbol { + /// Method Name + pub name: String, + /// Document URl containing the method definition. + pub url: Url, + /// Source range of the method definition. + pub location: Range, + /// Ranges of references/uses associated with this symbol. + pub references: Vec, + /// Ranges of outgoing method calls associated with this symbol. + pub method_dependencies: Vec, +} + +/// A class definition symbol (definition site + liveness flag). +#[derive(Clone, Debug)] +pub struct ClassGlobalSymbol { + /// Class Name + pub name: String, + /// Document URl containing the class definition. + pub url: Url, + /// Source range of the class definition. + pub location: Range, + /// Whether this symbol currently represents a live document/class (false after removal). + pub alive: bool, +} + +/// A private variable symbol (definition + references + dependency metadata). +#[derive(Clone, Debug)] +pub struct VariableSymbol { + /// Source range of the variable definition. + pub location: Range, + /// Source ranges of references/uses associated with this symbol. + pub references: Vec, + /// Names of other variables referenced by this definition. + pub var_dependencies: Vec, +} + +/// A Private Method Symbol +#[derive(Clone, Debug)] +pub struct MethodSymbol { + /// Method name + pub name: String, + /// Source range of the method definition. + pub location: Range, + /// Ranges of references/uses associated with this symbol. + pub references: Vec<(Url, Range)>, + /// Ranges of dependencies associated with this symbol. + pub method_dependencies: Vec, + /// MethodRef associated with this method. + pub method_ref: MethodRef, +} diff --git a/objectscript-lsp/crates/objectscript-core/src/scope_tree.rs b/objectscript-lsp/crates/objectscript-core/src/scope_tree.rs new file mode 100644 index 0000000..262d071 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/scope_tree.rs @@ -0,0 +1,359 @@ +use crate::common::{generic_exit_statements, point_in_range}; +use crate::parse_structures::{ClassId, MethodId, MethodRef, VariableRef}; +use crate::scope_structures::*; +use std::collections::HashMap; +use tree_sitter::{Point, Range}; + +/// A lexical scope within a document. +#[derive(Clone, Debug)] +pub struct Scope { + /// Start Point of Scope. + pub start: Point, + /// End Point of Scope. + pub end: Point, + /// Optional: Id of Parent Scope. + pub parent: Option, + /// Ids of Child Scopes. + pub children: Vec, + /// Stores the Variable Symbols defined in this scope. + pub variable_symbols: Vec, + /// Stores variable name -> VariableRef for public variables defined in this scope. + pub public_var_defs: HashMap>, + /// Stores variable name -> VariableRef for private variables defined in this scope. + pub private_variable_defs: HashMap>, + /// True if Scope is only new for the variables from a new command + pub is_new_scope: bool, + /// Optional: Name of method that this scope is a part of + pub method: Option, +} +impl Scope { + /// Create a new scope node with the given bounds and optional parent. + fn new( + start: Point, + end: Point, + parent: Option, + is_new_scope: bool, + method_name: Option, + ) -> Self { + Self { + start, + end, + parent, + children: Vec::new(), + variable_symbols: Vec::new(), + public_var_defs: HashMap::new(), // HashMap var name -> GlobalSymbol + private_variable_defs: HashMap::new(), + is_new_scope, + method: method_name, + } + } + + /// Returns a reference to the variable symbol at the given index. + pub fn get_variable_symbol(&self, index: usize) -> Option<&VariableSymbol> { + self.variable_symbols.get(index) + } + + /// Look up a private variable definition in this scope by name and return its source range. + /// + /// Logs a warning and returns `None` if the name is not present or the stored symbol id is + /// out of bounds for `variable_symbols`. + pub fn get_variable_location(&self, variable_name: &str, point: Point) -> Vec { + let mut variable_locations = Vec::new(); + if let Some(variable_refs) = self.private_variable_defs.get(variable_name) { + for variable_ref in variable_refs { + if variable_ref.pub_id.is_some() { + return variable_locations; + } + if let Some(variable_id) = variable_ref.priv_id + && let Some(variable_symbol) = self.variable_symbols.get(variable_id.0) + { + if (variable_symbol.location.start_point.row < point.row) + || ((variable_symbol.location.start_point.row == point.row) + && (variable_symbol.location.start_point.column < point.column)) + { + variable_locations.push(variable_symbol.location); + } + } + } + } + variable_locations + } + + /// Define a new private variable symbol in this scope and return its `VariableRef`. + pub fn new_variable_symbol( + &mut self, + name: String, + location: Range, + var_dependencies: Vec, + var_ref: VariableRef, + ) { + if let Some(i) = var_ref.priv_id { + if i.0 != self.variable_symbols.len() { + eprintln!( + "ERROR: Variable Index is NOT equivalent to the variable symbols index (new_variable_symbol, scope tree)" + ); + } + self.private_variable_defs + .entry(name) + .or_insert_with(Vec::new) + .push(var_ref); + self.variable_symbols.push(VariableSymbol { + location, + references: Vec::new(), + var_dependencies, + }); + } + } + + /// Record a public variable definition in this scope by mapping its name to a global symbol id. + pub fn new_symbol_pub_variable(&mut self, name: String, variable_reference: VariableRef) { + self.public_var_defs + .entry(name) + .or_insert_with(Vec::new) + .push(variable_reference); + } + + /// Look up a public variable definition in this scope by name. + /// + /// Logs a warning and returns `None` if the name is not present. + pub fn get_pub_variable_symbol(&self, name: &str) -> Vec { + if let Some(var_references) = self.public_var_defs.get(name).cloned() { + return var_references; + } + Vec::new() + } +} + +/// Per-document scope index used for symbol lookup and resolution. +#[derive(Debug)] +pub struct ScopeTree { + /// Stores ScopeId -> Scope for all Scopes in the document. + pub scopes: HashMap, + /// The root ScopeId, which spans the whole document. + pub root: ScopeId, + /// The iterator that keeps track of the Id to assign to the next scope. + pub next_scope_id: usize, + /// Stores methodId -> Method Symbol for all private methods in the document. + pub private_method_defs: HashMap, + /// The Id corresponding to the class definition symbol for this document when this is a class file. + pub class_def: Option, +} + +impl Clone for ScopeTree { + /// Clone the ScopeTree + fn clone(&self) -> Self { + Self { + scopes: self.scopes.clone(), + root: self.root, + next_scope_id: self.next_scope_id, + private_method_defs: self.private_method_defs.clone(), + class_def: self.class_def, + } + } +} + +impl ScopeTree { + /// Creates a new scope tree with a root scope spanning the entire document. + pub fn new(class_def: Option) -> Self { + let root_id = ScopeId(0); + let root_scope = Scope::new( + Point { row: 0, column: 0 }, + Point { + row: usize::MAX, + column: usize::MAX, + }, + None, + false, + None, + ); + let mut scopes = HashMap::new(); + scopes.insert(root_id, root_scope); + Self { + scopes, + root: root_id, + next_scope_id: 1, + private_method_defs: HashMap::new(), + class_def, + } + } + + /// If `var_name` is a public variable visible at `pos`, return its owning class symbol id and + /// the variable's global symbol id. + pub fn pub_variable_in_scope(&self, pos: Point, var_name: &str) -> Vec { + let Some(scope) = self.get_scope(pos) else { + eprintln!( + "Error: Failed to get scope in (scope_tree, pub_variable_in_scope) for variable {:?}", + var_name + ); + return Vec::new(); + }; + + let refs = scope.get_pub_variable_symbol(var_name); + if refs.is_empty() { + return Vec::new(); + } + refs + } + + /// Look up a private method symbol by name. + /// + /// Logs a warning and returns `None` if it does not exist. + pub fn get_private_method_symbol(&self, method_id: &MethodId) -> Option<&MethodSymbol> { + self.private_method_defs.get(method_id) + } + + /// Add a new child scope to `parent`, returning the new `ScopeId`. + pub fn add_scope( + &mut self, + start: Point, + end: Point, + parent: ScopeId, + is_new_scope: bool, + method_name: Option, + ) -> ScopeId { + let scope_id = ScopeId(self.next_scope_id); + self.next_scope_id += 1; + let scope = Scope::new(start, end, Some(parent), is_new_scope, method_name); + // update parent to include this scope as a child + if let Some(parent_scope) = self.scopes.get_mut(&parent) { + parent_scope.children.push(scope_id); + } + self.scopes.insert(scope_id, scope); + scope_id + } + + /// Inserts a private variable symbol into the scope containing its start point. + pub fn new_variable_symbol( + &mut self, + name: String, + range: Range, + var_deps: Vec, + variable_reference: VariableRef, + ) { + let Some(scope) = self.get_mut_scope(range.start_point) else { + eprintln!("Error: couldn't get scope for variable {:?}", name); + return; + }; + scope.new_variable_symbol(name, range, var_deps, variable_reference) + } + + /// Register a private method definition symbol in this document. + pub fn new_method_symbol(&mut self, name: String, range: Range, method_ref: MethodRef) { + let method_symbol = MethodSymbol { + name: name.clone(), + location: range, + references: Vec::new(), + method_dependencies: Vec::new(), + method_ref, + }; + self.private_method_defs + .insert(method_ref.id, method_symbol); + } + + /// Get a mutable reference to the innermost scope containing `point`. + /// + /// Logs a warning and returns `None` if no containing scope is found. + fn get_mut_scope(&mut self, point: Point) -> Option<&mut Scope> { + let Some(scope_id) = self.find_current_scope(point) else { + eprintln!("Warning: Scope Id not found for Point {:?}", point); + return None; + }; + + let scopes = self.scopes.clone(); + let Some(scope) = self.scopes.get_mut(&scope_id) else { + eprintln!( + "Warning: Scope not found, Scope Id {:?} DNE in scopes hashmap: \n {:?} \n\n", + scope_id, scopes + ); + return None; + }; + Some(scope) + } + + /// Get an immutable reference to the innermost scope containing `point`. + /// + /// Logs a warning and returns `None` if no containing scope is found. + fn get_scope(&self, point: Point) -> Option<&Scope> { + let Some(scope_id) = self.find_current_scope(point) else { + eprintln!("Warning: Scope Id not found for Point {:?}", point); + return None; + }; + let Some(scope) = self.scopes.get(&scope_id) else { + eprintln!( + "Warning: Scope not found, Scope Id {:?} DNE in scopes hashmap: \n {:?} \n\n", + scope_id, self.scopes + ); + return None; + }; + + Some(scope) + } + + /// Record a public variable symbol in the scope that contains `range.start_point`. + pub fn new_public_var_symbol( + &mut self, + name: String, + range: Range, + variable_reference: VariableRef, + ) { + let Some(scope) = self.get_mut_scope(range.start_point) else { + generic_exit_statements("Scope", "new_public_var_symbol"); + return; + }; + scope.new_symbol_pub_variable(name.clone(), variable_reference); + } + + /// Returns the method name associated with the scope containing the given position. + pub fn get_method_name(&self, pos: Point) -> Option { + let Some(scope) = self.get_scope(pos) else { + generic_exit_statements("Scope", "get_method_name"); + return None; + }; + scope.method.clone() + } + + /// Returns a reference to a variable symbol in the scope at the given position. + pub fn get_variable_symbol(&self, pos: Point, variable_id: usize) -> Option<&VariableSymbol> { + let Some(scope) = self.get_scope(pos) else { + generic_exit_statements("Scope", "get_variable_definition"); + return None; + }; + + scope.get_variable_symbol(variable_id) + } + + /// Look up a private variable definition visible at `pos` by name. + pub fn get_variable_definition(&self, pos: Point, variable_name: &str) -> Vec { + let Some(scope) = self.get_scope(pos) else { + generic_exit_statements("Scope", "get_variable_definition"); + return Vec::new(); + }; + + scope.get_variable_location(variable_name, pos) + } + + /// Find the innermost scope containing `pos` by descending from the root into matching children. + pub fn find_current_scope(&self, pos: Point) -> Option { + let mut current = self.root; + + loop { + let Some(scope) = self.scopes.get(¤t) else { + return None; + }; + // iterate over children vector (which contains scopeid values) + // searches for the first child that satisfies the condition of containing the point + let child = scope.children.iter().find(|&&child_id| { + let Some(child_scope) = self.scopes.get(&child_id) else { + return false; + }; + point_in_range(pos, child_scope.start, child_scope.end) + }); + match child { + Some(&child_id) => current = child_id, + None => { + return Some(current); + } + } + } + } +} diff --git a/objectscript-lsp/crates/objectscript-core/src/variable.rs b/objectscript-lsp/crates/objectscript-core/src/variable.rs new file mode 100644 index 0000000..15e6d36 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/variable.rs @@ -0,0 +1,23 @@ +use crate::parse_structures::{ReturnType, Variable}; + +impl Variable { + /// Construct a `Variable` with an optional declared argument type and inferred expression types. + /// + /// `arg_type` is typically set for method arguments, while `var_type` represents the inferred + /// types/atoms observed in the RHS/default expression. + pub fn new( + var_name: String, + arg_type: Option, + is_public: bool, + is_oref: bool, + cls: Option, + ) -> Self { + Self { + name: var_name, + arg_type, + is_public, + is_oref, + cls, + } + } +} diff --git a/objectscript-lsp/crates/objectscript-core/src/workspace.rs b/objectscript-lsp/crates/objectscript-core/src/workspace.rs new file mode 100644 index 0000000..f90ab48 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/workspace.rs @@ -0,0 +1,2164 @@ +use crate::common::{ + find_class_definition, generic_exit_statements, get_identifier_from_method_arg, + get_member_name_from_root, get_node_children, get_routine_range, get_string_at_byte_range, + initial_build_scope_tree, parse_line_ref, point_to_byte, +}; +use crate::config::Config; +use crate::dependency_tracker::{DependencyGraph, Dependents}; +use crate::document::Document; +use crate::global_semantic::GlobalSemanticModel; +use crate::local_semantic::LocalSemanticModel; + +use crate::override_index::OverrideIndex; +use crate::parse_structures::{ + Class, ClassId, FileType, Language, MethodId, MethodRef, RefactorLevel, VariableRef, +}; +use crate::refactor::{ + refactor_conditionals, refactor_for_statements, refactor_legacy_do_statements, +}; + +use crate::scope_structures::MethodGlobalSymbol; +use crate::scope_tree::ScopeTree; +use parking_lot::{Mutex, RwLock}; +use std::collections::HashMap; +use std::fmt::Debug; +use std::path::PathBuf; +use std::sync::OnceLock; +use tower_lsp::lsp_types::Url; +use tree_sitter::{ + Language as TsLanguage, Node, Parser, Point, Query, QueryCursor, Range, StreamingIterator, Tree, +}; +use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; +use tree_sitter_objectscript_routine::LANGUAGE_OBJECTSCRIPT_ROUTINE; +use tree_sitter_xml::LANGUAGE_XML; + +/// Holds Tree-sitter parsers for each supported ObjectScript file grammar. +pub struct WorkspaceParsers { + /// Parser for `.mac` / `.inc` routine files. + pub routine: Mutex, + /// Parser for `.cls` class-definition files. + pub cls: Mutex, + /// Parser for `.xml` export files. + pub xml: Mutex, +} + +impl Debug for WorkspaceParsers { + fn fmt(&self, _: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Ok(()) + } +} + +impl WorkspaceParsers { + /// Construct a `WorkspaceParsers` with ObjectScript grammars initialized. + /// + /// - `cls` uses `LANGUAGE_OBJECTSCRIPT_UDL` + /// - `routine` uses `LANGUAGE_OBJECTSCRIPT_ROUTINE` + /// - `xml` uses `LANGUAGE_XML` + /// + /// Panics if either grammar fails to load (intended to fail-fast during startup). + pub fn new() -> Self { + let mut cls_parser = Parser::new(); + cls_parser + .set_language(&LANGUAGE_OBJECTSCRIPT_UDL.into()) + .expect("Error loading ObjectScript UDL grammar"); + + let mut routine_parser = Parser::new(); + routine_parser + .set_language(&LANGUAGE_OBJECTSCRIPT_ROUTINE.into()) + .expect("Error loading ObjectScript routine grammar"); + + let mut xml_parser = Parser::new(); + xml_parser + .set_language(&LANGUAGE_XML.into()) + .expect("Error loading XML grammar"); + + Self { + routine: Mutex::new(routine_parser), + cls: Mutex::new(cls_parser), + xml: Mutex::new(xml_parser), + } + } +} + +/// Stores all workspace-wide state needed to serve LSP features. +/// +/// `ProjectData` is the in-memory “database” for a single workspace: it owns the +/// current configuration, parsed documents, semantic models, and symbol indexes +/// used for lookups like go-to-definition, references, and override resolution. +/// +#[derive(Debug)] +pub struct ProjectData { + /// Stores the User Settings for this Workspace. + pub config: Config, + /// Maps Url -> Document for each `.cls`, `.mac`, and `.inc` document in this Workspace. + pub documents: HashMap, + /// Stores all semantic information for this Workspace. + pub global_semantic_model: GlobalSemanticModel, + /// Maps class name -> ClassId(index) for each class in this workspace. + pub classes: HashMap, + // /// Maps Class Name -> another hashmap which maps Method Name -> MethodGlobalSymbolId for all public methods + pub method_defs: HashMap>, + /// Maps Var Name -> another hashmap which maps MethodRef -> Vec for that variable. + pub pub_var_defs: HashMap>>, + /// Holds the OverrideIndex for the workspace. + pub override_index: OverrideIndex, + /// Reverse inheritance index used by hierarchy-aware public variable lookup. + pub dependent_class_index: Dependents, + /// Graph of all calls to methods/procedures/subroutines for each class + pub dependency_graph: DependencyGraph, +} + +/// Concurrency wrapper for a workspace’s state and parsers. +/// +/// `ProjectState` holds the project root path and a lock-protected `ProjectData`, +/// along with Tree-sitter parsers shared across requests. This is the primary +/// entry point for workspace-level operations (open/update/index). +#[derive(Debug)] +pub struct ProjectState { + /// Workspace root path (set once during initialize()). + pub project_root_path: OnceLock>, + /// Lock-protected workspace data (documents, semantics, symbols, indexes). + pub data: RwLock, + /// Reusable parsers for `.cls` and routine files. + pub parsers: WorkspaceParsers, +} + +impl ProjectData { + /// Return basic immutable snapshot information for a document. + /// + /// Produces `(file_type, content, version, tree)` for the document at `url`. The text and tree + /// are cloned so callers can use them without holding a borrow on `ProjectData`. + /// + /// Returns `None` if the document is not currently tracked. + pub fn get_document_info(&self, url: &Url) -> Option<(FileType, String, i32, Tree)> { + let Some(document) = self.get_document(url) else { + generic_exit_statements("ProjectData", "get_document_info"); + return None; + }; + let curr_version = document.version.unwrap_or(0); + let current_text = document.content.clone(); + let curr_tree = document.tree.clone(); + Some(( + document.file_type.clone(), + current_text, + curr_version, + curr_tree, + )) + } + + /// Add a document only if it is not already present. + /// Returns true if the document was present, false otherwise. + pub fn add_document_if_absent( + &mut self, + url: Url, + code: String, + tree: Tree, + filetype: FileType, + class_name: Option, + version: Option, + ) -> bool { + if self.documents.contains_key(&url) { + eprintln!("Document already exists for file at :{:?}", url.path()); + return true; + } + self.add_document(url, code, tree, filetype, class_name, version); + false + } + + /// Refactor a document. Refactoring options are in `RefactorLevel`, and include + /// `Refactor Legacy Dotted Do Statements`, `Refactor Legacy If/Else Statements` + /// `Refactor Legacy For Statements`, or do all three actions. + pub fn refactor_document(&self, url: &Url, refactor_level: RefactorLevel) -> Option { + let (filetype, content) = { + let Some(document) = self.get_document(url) else { + eprintln!( + "Tried to refactor document {:?}, but it does not exist ", + url.path() + ); + return None; + }; + (document.file_type.clone(), document.content.clone()) + }; + if filetype == FileType::Xml { + return None; + } + let updated_content = match refactor_level { + RefactorLevel::DoCommands => { + if filetype != FileType::Routine { + return None; + } + refactor_legacy_do_statements(content.as_str()) + } + RefactorLevel::Conditionals => { + refactor_conditionals(content.as_str(), filetype.clone()) + } + RefactorLevel::ForCommands => { + refactor_for_statements(content.as_str(), filetype.clone()) + } + RefactorLevel::All => { + let content_after_do_refactor = if filetype == FileType::Routine { + refactor_legacy_do_statements(content.as_str()) + } else { + content.clone() + }; + let content_after_if_refactor = + refactor_conditionals(content_after_do_refactor.as_str(), filetype.clone()); + refactor_for_statements(content_after_if_refactor.as_str(), filetype.clone()) + } + }; + if content == updated_content.as_str() { + None + } else { + Some(updated_content) + } + } + + /// Refactor all ObjectScript documents in this workspace. Refactoring options are in `RefactorLevel`, and include + /// `Refactor Legacy Dotted Do Statements`, `Refactor Legacy If/Else Statements` + /// `Refactor Legacy For Statements`, or do all three actions. + pub fn refactor(&self, refactor_level: RefactorLevel) -> Vec<(String, Url)> { + let mut changed = Vec::new(); + let urls: Vec<&Url>; + if refactor_level == RefactorLevel::DoCommands { + urls = self + .documents + .iter() + .filter_map(|(url, document)| { + (document.file_type == FileType::Routine).then_some(url) + }) + .collect(); + } else { + urls = self.documents.keys().collect(); + } + for url in urls { + let Some(refactored) = self.refactor_document(url, refactor_level) else { + continue; + }; + changed.push((refactored, url.clone())); + } + changed + } + + /// Parse and register a new document, initializing semantic + symbol state for `.cls` files. + /// + /// For class files (`FileType::Cls`), this: + /// - Extracts the class definition/range + /// - Builds an initial `Class` and method list from the tree-sitter tree + /// - Creates a `ClassGlobalSymbol`, `ScopeTree`, and `Document` + /// - Adds public methods into the global semantic model and method symbol tables + /// - Adds private methods into the local semantic model and scope tree symbols + /// - Registers class ids and local semantic model ids for later rebuilds + /// + /// Non-CLS file types are currently ignored by this function. + pub fn add_document( + &mut self, + url: Url, + code: String, + tree: Tree, + filetype: FileType, + class_name: Option, + version: Option, + ) { + if filetype == FileType::Xml { + let document = Document::new(code, tree, filetype, None, ScopeTree::new(None), version); + self.documents.insert(url, document); + return; + } else if filetype == FileType::Routine || filetype == FileType::Cls { + let Some(member_name) = class_name else { + eprintln!( + "Error: missing class name while adding cls document for url: {}", + url.path() + ); + return; + }; + let content = code.as_str(); + let mut local_semantic_model = LocalSemanticModel::new(); + let is_rtn = if filetype == FileType::Routine { + true + } else { + false + }; + let mut class = Class::new(member_name.clone(), is_rtn); + let starting_node = if is_rtn { + tree.root_node() + } else { + let Some(node) = find_class_definition(tree.root_node()) else { + eprintln!( + "Error: Failed to find class definition for class named {:?}", + member_name + ); + return; + }; + node + }; + let cls_range; + if is_rtn { + if let Some(rtn_range) = get_routine_range(tree.root_node()) { + cls_range = rtn_range; + } else { + cls_range = starting_node.range(); + } + } else { + cls_range = starting_node.range(); + }; + let methods = class.initial_build(starting_node, content, is_rtn); + + let cls_id = ClassId(self.global_semantic_model.next_id()); + let scope_tree = initial_build_scope_tree(tree.clone(), cls_id, content, is_rtn); + let mut document = Document::new( + code, + tree, + filetype, + Some(member_name.clone()), + scope_tree, + version, + ); + // class id dne yet, because it gets added after. instead, we can just create the method ids here + for (method, method_range, curr_method_id) in methods { + let method_name = method.name.clone(); + let method_id = MethodId(curr_method_id); + let method_ref = MethodRef { + class: cls_id, + id: method_id, + offset: None, + }; + if method.is_public { + // add method to global semantic model + + self.dependency_graph.get_or_add_node(method_ref); + // add methodId to class public methods field + class.methods.insert(method_name.clone(), method_ref); + // creates method global symbol in global semantic model + self.global_semantic_model.new_method_symbol( + method_name.clone(), + method_range, + url.clone(), + method_ref, + ); + self.global_semantic_model.new_method(method, method_ref); + // add method symbol + self.method_defs + .entry(member_name.clone()) + .or_insert_with(HashMap::new) + .insert(method_name.clone(), method_ref); + } else { + // add method to local semantic model + local_semantic_model.new_method(method, method_ref); + self.dependency_graph.get_or_add_node(method_ref); + // add methodId to class private methods field + class.methods.insert(method_name.clone(), method_ref); + // find current scope and build symbol and add it to the scope + // this creates the symbol and adds the symbol id to the scope tree + document.scope_tree.new_method_symbol( + method_name.clone(), + method_range, + method_ref, + ); + self.method_defs + .entry(member_name.clone()) + .or_insert_with(HashMap::new) + .insert(method_name.clone(), method_ref); + } + } + // add class to global semantic model + self.global_semantic_model.new_class(class, cls_id); + self.global_semantic_model.new_class_symbol( + member_name.clone(), + cls_range, + url.clone(), + cls_id, + ); + // add class id corresponding to class struct + self.classes.insert(member_name.clone(), cls_id); + + self.global_semantic_model + .new_local_semantic(cls_id, local_semantic_model); + // this creates the symbol and adds the symbol id to the scope tree + document.class_id = Some(cls_id); + self.documents.insert(url.clone(), document); + } + } + + /// Update a tracked document after text edits or reparse. + /// + /// This function: + /// - Re-parses/derives the current class name from the new `tree` + `content` + /// - Rebuilds the document's scope tree + /// - Clears old symbol/semantic state for the document (class/method/variable symbols, local model) + /// - Rebuilds class + method headers into semantic models (`rebuild_semantics`) + /// - Updates the stored `Document` fields (content/tree/version/type/name) + /// - Recomputes imports, inheritance, overrides, calls, and variables for the project + pub fn update_document( + &mut self, + url: Url, + tree: Tree, + file_type: FileType, + version: i32, + content: &str, + ) { + if file_type == FileType::Xml { + let Some(document) = self.get_document_mut(&url) else { + generic_exit_statements("ProjectData", "update_document"); + return; + }; + document.version = Some(version); + document.file_type = file_type; + document.tree = tree; + document.content = content.to_string(); + document.class_name = None; + document.class_id = None; + document.scope_tree = ScopeTree::new(None); + return; + } + + if file_type == FileType::Routine || file_type == FileType::Cls { + // a routine will be represented as a class in the workspace + let is_rtn = if file_type == FileType::Routine { + true + } else { + false + }; + + let Some(member_name) = get_member_name_from_root(content, tree.root_node(), is_rtn) + else { + eprintln!( + "Error: Failed to get name from root node for file url: {:?}", + url.path() + ); + return; + }; + let (cls_id, old_member_name) = { + let Some(doc) = self.get_document(&url) else { + eprintln!( + "Error: Document for url {:?} DNE aborting update_document", + url.path() + ); + return; + }; + let Some(cls_id) = doc.class_id else { + eprintln!( + "Error: Class ID for document {:?} DNE aborting update_document", + doc + ); + return; + }; + let Some(old_member_name) = doc.class_name.clone() else { + eprintln!( + "Error: Name for document {:?} DNE aborting update_document", + doc + ); + return; + }; + (cls_id, old_member_name) + }; + + let mut old_methods: Vec = Vec::new(); + if let Some(old_class) = self.global_semantic_model.get_class(&cls_id) { + old_methods = old_class.methods.values().cloned().collect(); + } + + { + let Some(doc) = self.get_document_mut(&url) else { + generic_exit_statements("ProjectData", "update_document"); + return; + }; + doc.scope_tree = initial_build_scope_tree(tree.clone(), cls_id, content, is_rtn); + } + // TODO: Make this incremental + self.global_semantic_model + .remove_document_symbols(&cls_id, &old_methods); + self.global_semantic_model + .reset_doc_semantics(&cls_id, member_name.clone()); + self.method_defs.remove(&old_member_name); + self.classes.remove(&old_member_name); + self.classes.insert(member_name.clone(), cls_id); + for (_, class_map) in &mut self.pub_var_defs { + for method_ref in &old_methods { + if class_map.contains_key(method_ref) { + class_map.remove(method_ref); + } + } + } + + let starting_node = if file_type == FileType::Routine { + tree.root_node() + } else { + let Some(node) = find_class_definition(tree.root_node()) else { + eprintln!( + "Error: Failed to find class definition for class named {:?}", + member_name + ); + return; + }; + node + }; + + self.rebuild_semantics( + url.clone(), + starting_node, + content, + cls_id, + member_name.clone(), + file_type.clone(), + ); + + { + let Some(document) = self.get_document_mut(&url) else { + generic_exit_statements("ProjectData", "update_document"); + return; + }; + document.version = Some(version); + document.file_type = file_type; + document.tree = tree; + document.content = content.to_string(); + document.class_name = Some(member_name); + document.class_id = Some(cls_id); + } + + self.build_inheritance_and_variables(Some(url), Vec::new()); + return; + } + } + + /// Rebuild class + method header semantics for a document after a reparse. + /// + /// This reconstructs the `Class` for `class_id` from the given class definition `node` (for .cls) or root `node` (for routines), then: + /// - Updates the class symbol (name/range/url) + /// - Re-registers public methods and method symbols into the global semantic model + /// - Re-registers private methods into the local semantic model and scope tree + /// - Replaces the class slot in the global semantic model at `class_id` + /// + /// Note: This function does not rebuild statement-level variables/calls; those are handled by + /// `build_inheritance_and_variables`. + pub fn rebuild_semantics( + &mut self, + url: Url, + node: Node, + content: &str, + class_id: ClassId, + class_name: String, + file_type: FileType, + ) { + let is_rtn = if file_type == FileType::Routine { + true + } else { + false + }; + // build vec of public methods to add to gsm at the end + let mut class = Class::new(class_name.clone(), is_rtn); + let methods = class.initial_build(node, content, is_rtn); + self.global_semantic_model.update_class_symbol( + class_name.clone(), + node.range(), + url.clone(), + &class_id, + ); + // class id dne yet, because it gets added after. instead, we can just create the method ids here + for (method, method_range, id) in methods { + let method_name = method.name.clone(); + let method_id = MethodId(id); + let method_ref = MethodRef { + class: class_id, + id: method_id, + offset: None, + }; + if method.is_public { + self.global_semantic_model.new_method(method, method_ref); + self.global_semantic_model.new_method_symbol( + method_name.clone(), + method_range, + url.clone(), + method_ref, + ); + // add method symbol + self.method_defs + .entry(class_name.clone()) + .or_insert_with(HashMap::new) + .insert(method_name.clone(), method_ref); + // add methodId to class public methods field + class.methods.insert(method_name.clone(), method_ref); + } else { + // add method to local semantic model + let Some(lsm) = self.global_semantic_model.get_local_semantic_mut(&class_id) else { + eprintln!("Error: Failed to get local semantic model from gsm"); + continue; + }; + lsm.active = true; + lsm.new_method(method, method_ref); + // add methodId to class private methods field + class.methods.insert(method_name.clone(), method_ref); + // find current scope and build symbol and add it to the scope + let Some(document) = self.get_document_mut(&url) else { + return; + }; + document.scope_tree.new_method_symbol( + method_name.clone(), + method_range, + method_ref, + ); + self.method_defs + .entry(class_name.clone()) + .or_insert_with(HashMap::new) + .insert(method_name.clone(), method_ref); + } + } + self.global_semantic_model.classes.insert(class_id, class); + let Some(doc) = self.get_document_mut(&url) else { + return; + }; + doc.class_id = Some(class_id); + } + + /// Compute imports, inheritance, override resolution, call sites, and variable symbols. + /// + /// If `only` is provided, only that document is scanned for import/extends changes; the + /// inheritance/override index is still rebuilt globally, and method calls/variables are + /// recomputed for all classes in the semantic model. + pub fn build_inheritance_and_variables(&mut self, only: Option, exclude: Vec) { + let mut indices_to_exclude = Vec::new(); + if let Some(url) = only { + if exclude.contains(&url) { + eprintln!( + "Error: Cannot specify the same URL in both only and exclude fields, aborting build_inheritance_and_variables" + ); + return; + } + + let document_class_id = { + let Some(document) = self.documents.get(&url) else { + eprintln!( + "Error: Failed to get document for url {:?}, aborting build_inheritance_and_variables", + url.path() + ); + return; + }; + let Some(class_id) = document.class_id else { + eprintln!( + "Error: Failed to get class id for url {:?}, aborting build_inheritance_and_variables", + url.path() + ); + return; + }; + class_id + }; + + // Snapshot inherited_classes before recomputing extends, so we can + // detect which classes got newly resolved parents. + let old_inherited: HashMap> = self + .global_semantic_model + .classes + .iter() + .map(|(&id, c)| (id, c.inherited_classes.clone())) + .collect(); + + // Recompute extends/imports for ALL classes so the override index + // sees correct inherited_classes even when documents were added out + // of order (e.g. subclass opened before its superclass was indexed). + let all_urls: Vec = self.documents.keys().cloned().collect(); + for doc_url in &all_urls { + let Some(document) = self.documents.get(doc_url) else { + continue; + }; + let Some(cls_id) = document.class_id else { + continue; + }; + if document.file_type == FileType::Routine { + continue; + } + let doc_tree = document.tree.clone(); + let doc_content = document.content.clone(); + self.recompute_imports_for_url(&doc_tree, doc_content.as_str(), &cls_id); + self.recompute_extends_for_url(&doc_tree, doc_content.as_str(), &cls_id); + } + + // Any class whose inherited_classes changed needs variable/call + // rebuilding too — not just the target document. + let mut changed_class_ids: Vec = Vec::new(); + for (&cls_id, class) in &self.global_semantic_model.classes { + let old = old_inherited.get(&cls_id).map(|v| v.as_slice()).unwrap_or(&[]); + if old != class.inherited_classes.as_slice() { + changed_class_ids.push(cls_id.0); + } + } + + indices_to_exclude = self + .classes + .values() + .filter(|&class_id| { + class_id != &document_class_id + && !changed_class_ids.contains(&class_id.0) + }) + .map(|class_id| class_id.0) + .collect(); + } else { + let urls: Vec = self + .documents + .keys() + .cloned() + .into_iter() + .filter(|url| !exclude.contains(url)) + .collect(); + for url in &urls { + let (document_file_type, document_class_id, doc_tree, doc_content) = { + let Some(document) = self.documents.get(&url) else { + eprintln!( + "Error: Failed to get document for url {:?}, aborting build_inheritance_and_variables", + url.path() + ); + return; + }; + let Some(class_id) = document.class_id else { + eprintln!( + "Error: Failed to get class id for url {:?}, aborting build_inheritance_and_variables", + url.path() + ); + return; + }; + ( + document.file_type.clone(), + class_id, + document.tree.clone(), + document.content.clone(), + ) + }; + let is_rtn = if document_file_type == FileType::Routine { + true + } else { + false + }; + if !is_rtn { + self.recompute_imports_for_url( + &doc_tree, + doc_content.as_str(), + &document_class_id, + ); + self.recompute_extends_for_url( + &doc_tree, + doc_content.as_str(), + &document_class_id, + ); + } + } + for url in &exclude { + let Some(document) = self.documents.get(url) else { + eprintln!("Error: Document DNE for url {:?}", url.path()); + continue; + }; + if let Some(index) = document.class_id { + indices_to_exclude.push(index.0); + } + } + } + // Recompute inheritance + override index + self.global_semantic_model.class_keyword_inheritance(); + // currently tracks superclass -> all subclasses that are dependent on it + self.dependent_class_index = self.global_semantic_model.build_dependents(); + let idx = self.global_semantic_model.build_override_index(); + self.override_index = idx.clone(); + + // TODO: need to calculate which classes to actually rebuild semantics for + let class_indices: Vec = self.classes.values().cloned().collect(); + // Class name, method name -> Vec + for class_index in class_indices { + if indices_to_exclude.contains(&class_index.0) { + continue; + } + let (class_name, methods, is_procedure_block, default_language) = { + let Some(class) = self.global_semantic_model.get_class(&class_index) else { + continue; + }; + let is_procedure_block = if class.is_procedure_block.is_none() { + false + } else { + class.is_procedure_block.unwrap() + }; + let default_language = if class.default_language.is_none() { + Language::Objectscript + } else { + class.default_language.clone().unwrap() + }; + let methods = class.methods.clone(); + ( + class.name.clone(), + methods, + is_procedure_block, + default_language, + ) + }; + + let url = { + let Some(class_global_symbol) = + self.global_semantic_model.get_class_symbol(&class_index) + else { + eprintln!("Error: Class Symbol DNE for class named {:?}", &class_name); + continue; + }; + class_global_symbol.url.clone() + }; + let (content, tree, scope_tree_snapshot, file_type) = { + let Some(document) = self.get_document(&url) else { + eprintln!( + "Error: Document DNE for class named {:?} skipping this class in build_inheritance_and_variables", + &class_name + ); + continue; + }; + let content = document.content.clone(); + let tree = document.tree.clone(); + let scope_tree_snapshot = document.scope_tree.clone(); + let file_type = document.file_type.clone(); + (content, tree, scope_tree_snapshot, file_type) + }; + let content = content.as_str(); + let tree_root_node = tree.root_node(); + let is_rtn = if file_type == FileType::Routine { + true + } else { + false + }; + let language: TsLanguage; + if is_rtn { + language = LANGUAGE_OBJECTSCRIPT_ROUTINE.into(); + } else { + language = LANGUAGE_OBJECTSCRIPT_UDL.into(); + }; + // ---------- public methods ---------- + for (method_name, method_ref) in methods { + // inherit class keywords if not explicitly assigned + if !is_rtn { + let method = + if let Some(m) = self.global_semantic_model.get_mut_method(&method_ref) { + m + } else if let Some(lsm) = self + .global_semantic_model + .get_local_semantic_mut(&class_index) + && let Some(m) = lsm.get_method_mut(&method_ref) + { + m + } else { + continue; + }; + method.update_keywords(is_procedure_block, default_language.clone()); + } + + let loc = if let Some(s) = + self.get_public_method_symbol(class_name.as_str(), method_name.as_str()) + { + s.location + } else if let Some(s) = + scope_tree_snapshot.get_private_method_symbol(&method_ref.id) + { + s.location + } else { + continue; + }; + + let Some(method_definition_node) = + tree_root_node.named_descendant_for_byte_range(loc.start_byte, loc.end_byte) + else { + continue; + }; + + { + self.find_method_dependencies( + method_definition_node, + content, + &language, + class_name.as_str(), + &method_ref, + ); + } + + // Variables: compute first (immutable), then apply (mutable) to avoid long borrows + let var_results = { + let method = if let Some(m) = self.global_semantic_model.get_method(&method_ref) + { + m + } else if let Some(lsm) = + self.global_semantic_model.get_local_semantic(&class_index) + && let Some(m) = lsm.get_method(&method_ref) + { + m + } else { + continue; + }; + method.build_method_variable_defs(method_definition_node, content) + }; + + for (variable, variable_range, refs_to_other_vars) in var_results { + let var_name = variable.name.clone(); + let var_is_public = variable.is_public; + + if refs_to_other_vars.contains(&var_name) { + continue; + } + if var_is_public { + let variable_ref = self + .global_semantic_model + .new_variable(variable, method_ref); + { + let method = if let Some(m) = + self.global_semantic_model.get_mut_method(&method_ref) + { + m + } else if let Some(lsm) = self + .global_semantic_model + .get_local_semantic_mut(&class_index) + && let Some(m) = lsm.get_method_mut(&method_ref) + { + m + } else { + continue; + }; + method + .variables + .entry(var_name.clone()) + .or_insert_with(Vec::new) + .push(variable_ref); + } + + self.global_semantic_model.new_variable_symbol( + variable_range, + url.clone(), + refs_to_other_vars.clone(), + method_ref, + variable_ref, + ); + + { + let Some(document) = self.get_document_mut(&url) else { + continue; + }; + document.scope_tree.new_public_var_symbol( + var_name.clone(), + variable_range, + variable_ref, + ); + } + self.pub_var_defs + .entry(var_name.clone()) + .or_insert_with(HashMap::new) + .entry(method_ref.clone()) + .or_insert_with(Vec::new) + .push(variable_ref); + } else { + let variable_ref = { + let Some(lsm) = self + .global_semantic_model + .get_local_semantic_mut(&class_index) + else { + continue; + }; + lsm.new_variable(method_ref, variable) + }; + + { + let method = if let Some(m) = + self.global_semantic_model.get_mut_method(&method_ref) + { + m + } else if let Some(lsm) = self + .global_semantic_model + .get_local_semantic_mut(&class_index) + && let Some(m) = lsm.get_method_mut(&method_ref) + { + m + } else { + continue; + }; + method + .variables + .entry(var_name.clone()) + .or_insert_with(Vec::new) + .push(variable_ref); + } + + { + let Some(document) = self.get_document_mut(&url) else { + continue; + }; + document.scope_tree.new_variable_symbol( + var_name.clone(), + variable_range, + refs_to_other_vars, + variable_ref, + ); + } + } + } + } + } + } + + /// Recomputes the import list for the class defined in `url`. + /// + /// This scans the non-class-definition portion of the file (everything before the + /// trailing `class_definition` node) for `import_code` statements, resolves imported + /// class names to `ClassId`s using `self.classes`, and updates the corresponding + /// `Class.imports` entry in the global semantic model. + /// + /// If the document or owning class cannot be found, the function logs a warning and + /// returns early without modifying state. + fn recompute_imports_for_url(&mut self, tree: &Tree, content: &str, class_id: &ClassId) { + let source_file_children = get_node_children(tree.root_node()); + let mut imports = Vec::new(); + for class_child in source_file_children { + if class_child.kind() == "import_code" { + let import_code_children = get_node_children(class_child); + for import_child in import_code_children { + if import_child.kind() == "class_name" { + let Some(identifier) = import_child.named_child(0) else { + eprintln!( + "Error: class name child should exist at index 0, must update parsing in recompute_imports_for_url" + ); + continue; + }; + let Some(name) = get_string_at_byte_range(content, identifier.byte_range()) + else { + continue; + }; + if let Some(id) = self.classes.get(&name).copied() { + imports.push(id); + } + } + } + } + } + if let Some(class) = self.global_semantic_model.classes.get_mut(class_id) { + class.imports = imports; + } + } + + /// Recompute direct `extends` (inheritance) dependencies for the class defined in `url`. + /// + /// Parses the class definition's `class_extends` entries and updates `class.inherited_classes` + /// with direct parent `ClassId`s (when resolvable). This should be run before building the + /// override index, which assumes direct parents only. + fn recompute_extends_for_url(&mut self, tree: &Tree, content: &str, class_id: &ClassId) { + let mut inherited = Vec::new(); + let Some(node) = find_class_definition(tree.root_node()) else { + eprintln!( + "Error: Failed to find class definition for class, exiting recompute_extends_for_url", + ); + return; + }; + + let Some(possible_extends_node) = node.named_child(2) else { + eprintln!( + "Error: class definition node should always have a child at index 2, parsing error, fix recompute_extends_for_url" + ); + return; + }; + + if possible_extends_node.kind() == "class_extends" { + let class_extends_children = get_node_children(possible_extends_node); + for class_extends_child in class_extends_children { + if class_extends_child.kind() == "class_name" { + let Some(identifier) = class_extends_child.named_child(0) else { + eprintln!( + "Error: class name child should exist at index 0, must update parsing in recompute_imports_for_url" + ); + continue; + }; + let Some(name) = get_string_at_byte_range(content, identifier.byte_range()) + else { + continue; + }; + if let Some(id) = self.classes.get(&name).copied() { + inherited.push(id); + } + } + } + } + if let Some(class) = self.global_semantic_model.classes.get_mut(class_id) { + class.inherited_classes = inherited; + } + } + + /// Fetch a tracked document by URL. + /// + /// Returns `None` and logs an error if the URL is not present in `self.documents`. + fn get_document(&self, url: &Url) -> Option<&Document> { + let Some(document) = self.documents.get(url) else { + eprintln!("Error: Couldn't find document for url: {}", url.path()); + return None; + }; + Some(document) + } + + /// Fetch a tracked document by URL as a mutable reference. + /// + /// Returns `None` and logs an error if the URL is not present in `self.documents`. + fn get_document_mut(&mut self, url: &Url) -> Option<&mut Document> { + let Some(document) = self.documents.get_mut(url) else { + eprintln!("Error: Couldn't find document for url: {}", url.path()); + return None; + }; + Some(document) + } + + /// Lookup the global symbol (name/range/url) for a public method in a class. + /// + /// This first resolves the method's symbol id from `pub_method_defs[class_name][method_name]`, + /// then retrieves the `MethodGlobalSymbol` from the global semantic model. + fn get_public_method_symbol( + &self, + class_name: &str, + method_name: &str, + ) -> Option<&MethodGlobalSymbol> { + let Some(&sym_id) = self + .method_defs + .get(class_name) + .and_then(|m| m.get(method_name)) + else { + return None; + }; + + self.global_semantic_model.get_method_symbol(&sym_id) + } + + /// Returns the `Url` and `Range` that point to the method location for `method_name` + pub fn get_method_definition( + &self, + method_ref: &MethodRef, + offset: Option, + ) -> Vec<(Url, Range)> { + let mut locations = Vec::new(); + if let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(&method_ref.class) + && let Some(cls_doc) = self.get_document(&cls_sym.url) + { + if let Some(method_symbol) = self.global_semantic_model.get_method_symbol(method_ref) { + locations.push((method_symbol.url.clone(), method_symbol.location)); + return locations; + } else if let Some(method_symbol) = + cls_doc.scope_tree.get_private_method_symbol(&method_ref.id) + { + let sym_range = if let Some(offset) = offset { + let content = &cls_doc.content; + let new_start_point = Point { + row: method_symbol.location.start_point.row + offset, + column: method_symbol.location.start_point.column, + }; + let new_start_byte = point_to_byte(content, new_start_point); + let new_end_point = Point { + row: method_symbol.location.end_point.row + offset, + column: method_symbol.location.end_point.column, + }; + let new_end_byte = point_to_byte(content, new_end_point); + let new_range = Range { + start_byte: new_start_byte, + start_point: new_start_point, + end_byte: new_end_byte, + end_point: new_end_point, + }; + new_range + } else { + method_symbol.location + }; + locations.push((cls_sym.url.clone(), sym_range)); + return locations; + } + } + + return locations; + } + + /// Returns a Vec of `Url(s)` and `Range(s)` that correspond to the variable definition location for `variable_name` + /// If `variable_name` is defined in the given scope, that variable definition is returned. + /// Otherwise, if `variable_name` is public: + /// the `DependencyGraph` is used to determine all possible paths to the current node (`MethodRef`). + /// For each node (`MethodRef`) on the path, the corresponding method is checked to see if a variable + /// definition for `variable_name` exists. If so, the `Url` and `Range` of that definition is added. + /// All possible locations are returned. + /// If `variable_name` is private and not defined in the given scope, `None` is returned. + pub fn get_variable_definition( + &self, + url: &Url, + point: Point, + variable_name: String, + ) -> Vec<(Url, Range)> { + let mut locations = Vec::new(); + let Some(document) = self.get_document(url) else { + eprintln!( + "Error: failed to get document for file {:?}. Aborting get_variable_definition", + url.path() + ); + return locations; + }; + + let Some(class_name) = document.class_name.clone() else { + return locations; + }; + + let Some(method_name) = document.scope_tree.get_method_name(point) else { + return locations; + }; + + if let Some(method_ref) = self + .method_defs + .get(&class_name) + .and_then(|method_refs| method_refs.get(&method_name)) + { + let private_var_ranges = document + .scope_tree + .get_variable_definition(point, variable_name.as_str()); + if !private_var_ranges.is_empty() { + for var_range in private_var_ranges { + locations.push((url.clone(), var_range)); + } + return locations; + } + + let pub_var_refs = document + .scope_tree + .pub_variable_in_scope(point, variable_name.as_str()); + if !pub_var_refs.is_empty() { + for var_ref in pub_var_refs { + if let Some(id) = var_ref.pub_id + && let Some(var_symbol) = self + .global_semantic_model + .get_variable_symbol(method_ref, id.0) + { + locations.push((var_symbol.url.clone(), var_symbol.location)); + } + } + return locations; + } + + if self.is_variable_public(*method_ref, variable_name.clone()) { + if let Some(&node_index) = self.dependency_graph.get_node(*method_ref) + && let Some(public_var_definitions) = self.pub_var_defs.get(&variable_name) + { + let all_potential_nodes_on_path = + self.dependency_graph.all_ancestors(node_index); + for (def_method_ref, variable_refs) in public_var_definitions { + if all_potential_nodes_on_path.contains(def_method_ref) { + for variable_ref in variable_refs { + if let Some(var_id) = variable_ref.pub_id + && let Some(symbol) = self + .global_semantic_model + .get_variable_symbol(&def_method_ref, var_id.0) + { + locations.push((symbol.url.clone(), symbol.location)); + } + } + } + } + } + } + } + locations + } + + /// Resolve an object-reference method call to its definition location(s). + pub fn get_oref_method_definition( + &self, + oref_name: &str, + oref_method_name: &str, + curr_class: &str, + point: Point, + ) -> Vec<(Url, Range)> { + let method_refs: Vec = + self.find_classes_from_oref(oref_name, oref_method_name, curr_class, point); + let mut locations = Vec::new(); + for method_ref in method_refs { + if let Some(sym) = self.global_semantic_model.get_method_symbol(&method_ref) { + locations.push((sym.url.clone(), sym.location)); + } else if let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(&method_ref.class) + && let Some(method_ref_doc) = self.documents.get(&cls_sym.url) + && let Some(sym) = method_ref_doc + .scope_tree + .get_private_method_symbol(&method_ref.id) + { + locations.push((cls_sym.url.clone(), sym.location)); + } + } + locations + } + + /// This finds the oref (variable part) definition, it does NOT take you to corresponding class and method + /// If you want that, use find_classes_from_oref + pub fn get_oref_definition( + &self, + url: &Url, + point: Point, + variable_name: String, + oref_method_name: String, + ) -> Vec<(Url, Range)> { + let mut locations = Vec::new(); + let Some(document) = self.get_document(url) else { + eprintln!( + "Error: failed to get document for file {:?}. Aborting get_variable_definition", + url.path() + ); + return locations; + }; + + let Some(class_name) = document.class_name.clone() else { + return locations; + }; + + let Some(method_name) = document.scope_tree.get_method_name(point) else { + return locations; + }; + + if let Some(method_ref) = self + .method_defs + .get(&class_name) + .and_then(|method_refs| method_refs.get(&method_name)) + { + let Some(lsm) = self + .global_semantic_model + .get_local_semantic(&method_ref.class) + else { + return locations; + }; + let method = if let Some(m) = self.global_semantic_model.get_method(&method_ref) { + m + } else if let Some(m) = lsm.get_method(&method_ref) { + m + } else { + return locations; + }; + // true if variable is defined in current method + if let Some(variable_refs) = method.variables.get(&variable_name) { + for var_ref in variable_refs { + if let Some(var_id) = var_ref.pub_id { + if let Some(variable) = self + .global_semantic_model + .get_variable(method_ref, var_id.0) + && variable.is_oref + { + if let Some(var_class_name) = &variable.cls + && let Some(_) = self + .method_defs + .get(var_class_name) + .and_then(|methods| methods.get(&oref_method_name)) + { + let Some(var_sym) = self + .global_semantic_model + .get_variable_symbol(method_ref, var_id.0) + else { + continue; + }; + locations.push((var_sym.url.clone(), var_sym.location)); + } + } + } else if let Some(var_id) = var_ref.priv_id { + if let Some(variable) = lsm.get_variable(method_ref, var_id.0) + && variable.is_oref + { + if let Some(var_class_name) = &variable.cls + && let Some(_) = self + .method_defs + .get(var_class_name) + .and_then(|methods| methods.get(&oref_method_name)) + { + let Some(var_sym) = + document.scope_tree.get_variable_symbol(point, var_id.0) + else { + continue; + }; + locations.push((url.clone(), var_sym.location)); + } + } + } + } + return locations; + } else if self.is_variable_public(*method_ref, variable_name.clone()) { + if let Some(&node_index) = self.dependency_graph.get_node(*method_ref) + && let Some(public_var_definitions) = self.pub_var_defs.get(&variable_name) + { + let all_potential_nodes_on_path = + self.dependency_graph.all_ancestors(node_index); + for (def_method_ref, variable_refs) in public_var_definitions { + if all_potential_nodes_on_path.contains(def_method_ref) { + for variable_ref in variable_refs { + if let Some(var_id) = variable_ref.pub_id + && let Some(variable) = self + .global_semantic_model + .get_variable(def_method_ref, var_id.0) + && variable.is_oref + && let Some(symbol) = self + .global_semantic_model + .get_variable_symbol(&def_method_ref, var_id.0) + { + if let Some(var_class_name) = &variable.cls + && let Some(_) = self + .method_defs + .get(var_class_name) + .and_then(|methods| methods.get(&oref_method_name)) + { + locations.push((symbol.url.clone(), symbol.location)); + } + } + } + } + } + } + } + } + locations + } + + /// Finds the method struct representing the method that `variable_name` is defined in + /// and uses that to determine if the variable is public or private. + /// + /// Returns `true` if variable `variable_name` is public and `false` otherwise. + pub fn is_variable_public(&self, method_ref: MethodRef, variable_name: String) -> bool { + let method = if let Some(m) = self.global_semantic_model.get_method(&method_ref) { + m + } else if let Some(lsm) = self + .global_semantic_model + .get_local_semantic(&method_ref.class) + && let Some(m) = lsm.get_method(&method_ref) + { + m + } else { + return false; + }; + + let cls_is_procedure_block = + if let Some(c) = self.global_semantic_model.get_class(&method_ref.class) { + c.is_procedure_block.unwrap_or(true) + } else { + return false; + }; + + if let Some(is_procedure_block) = method.is_procedure_block { + if !is_procedure_block { + return true; + } + } else if !cls_is_procedure_block { + return true; + } else if method.public_variables_declared.contains(&variable_name) { + return true; + } + return false; + } + + /// Returns the `Url` and `Range` associated with the class `class_name` location. + pub fn get_class_definition(&self, class_name: &str) -> Vec<(Url, Range)> { + let Some(class_sym_id) = self.classes.get(class_name) else { + eprintln!( + "Error: Failed to find a class_sym_id in class: {:?}, aborting get_class_definition", + class_name + ); + return Vec::new(); + }; + let Some(sym) = self.global_semantic_model.get_class_symbol(class_sym_id) else { + eprintln!( + "Error: Failed to find a class symbol in global_semantic_model for class: {:?}, aborting get_class_definition", + class_name + ); + return Vec::new(); + }; + vec![(sym.url.clone(), sym.location)] + } + + /// Returns locations of classes that override the given class of class_id + pub fn get_class_implementations(&self, class_id: &ClassId) -> Vec<(Url, Range)> { + let mut locations = Vec::new(); + let Some(dependent_class_ids) = self.dependent_class_index.dependent_classes.get(class_id) + else { + eprintln!("Error: Classid {:?} has no implementations", class_id); + return locations; + }; + + for dep_id in dependent_class_ids { + let Some(class) = self.global_semantic_model.get_class(dep_id) else { + eprintln!( + "Error: Class of ClassId {:?} DNE, skipping (get_class_implementations)", + dep_id + ); + continue; + }; + + let cls_name = &class.name; + + let overriding_subclass_class_symbol_id = match self.classes.get(cls_name).copied() { + Some(id) => id, + None => { + eprintln!( + "Error: Class Symbol ID of ClassId {:?} DNE, skipping (get_class_implementations)", + dep_id + ); + continue; + } + }; + let Some(sym) = self + .global_semantic_model + .get_class_symbol(&overriding_subclass_class_symbol_id) + else { + eprintln!( + "Error: Class Symbol for symbol Id {:?} DNE, skipping (get_class_implementations)", + overriding_subclass_class_symbol_id + ); + continue; + }; + locations.push((sym.url.clone(), sym.location)); + } + locations + } + + /// Returns the location of the superclass method that the given subclass method overrides + pub fn get_method_superclass( + &self, + method_name: String, + class_id: &ClassId, + ) -> Vec<(Url, Range)> { + let method_name_str = method_name.as_str(); + let mut locations = Vec::new(); + let Some(class) = self.global_semantic_model.get_class(&class_id) else { + eprintln!("Error: Class struct DNE, aborting (get_method_superclass)",); + return locations; + }; + + if let Some(method_ref) = class.get_method_ref(method_name_str) { + let superclass_method_ref = match self.override_index.overrides.get(&method_ref) { + Some(v) => v, + None => { + eprintln!( + "Error: Method {:?} in subclass {:?} doesn't override any superclass method", + method_name_str, class.name + ); + return locations; + } + }; + + if let Some(superclass_method_symbol) = self + .global_semantic_model + .get_method_symbol(superclass_method_ref) + { + locations.push(( + superclass_method_symbol.url.clone(), + superclass_method_symbol.location, + )); + } + } + return locations; + } + + /// Returns the location(s) of the superclass(es) that the subclass inherits + pub fn get_class_superclasses(&self, class_id: &ClassId) -> Vec<(Url, Range)> { + let mut locations = Vec::new(); + if let Some(class) = self.global_semantic_model.get_class(class_id) { + for inherited_class_id in &class.inherited_classes { + let Some(inherited_class) = + self.global_semantic_model.get_class(inherited_class_id) + else { + eprintln!("Error: Inherited Class struct DNE, skipping",); + continue; + }; + let Some(inherited_class_sym) = self + .global_semantic_model + .get_class_symbol(inherited_class_id) + else { + eprintln!( + "Error: failed to get class symbol from global semantic model for class named {:?}, skipping in get_class_superclasses", + &inherited_class.name + ); + continue; + }; + locations.push(( + inherited_class_sym.url.clone(), + inherited_class_sym.location, + )) + } + } + locations + } + + /// Return locations of methods that override a given public method. + /// + /// Looks up the current document's class, confirms `method_name` is a public method, then uses + /// `override_index.overridden_by` to find overriding methods (public or private) in subclasses. + /// + /// Each returned `(Url, Range)` points to the overriding method's definition location. + pub fn get_method_overrides(&self, method_ref: &MethodRef) -> Vec<(Url, Range)> { + let mut locations = Vec::new(); + // ---- overridden-by list ---- + let overrides = match self.override_index.overridden_by.get(method_ref) { + Some(v) => v, + None => { + return locations; + } + }; + for override_method_ref in overrides { + let override_cls_url = if let Some(class_symbol) = self + .global_semantic_model + .get_class_symbol(&override_method_ref.class) + { + &class_symbol.url + } else { + continue; + }; + + if let Some(sym) = self + .global_semantic_model + .get_method_symbol(override_method_ref) + { + locations.push((sym.url.clone(), sym.location)); + } else if let Some(doc) = self.documents.get(override_cls_url) + && let Some(sym) = doc + .scope_tree + .get_private_method_symbol(&override_method_ref.id) + { + locations.push((override_cls_url.clone(), sym.location)); + } + } + locations + } + + fn find_classes_from_oref( + &self, + oref_name: &str, + oref_method_name: &str, + curr_class: &str, + point: Point, + ) -> Vec { + let mut oref_method_refs = Vec::new(); + let url = if let Some(class_id) = self.classes.get(curr_class) + && let Some(class_sym) = self.global_semantic_model.get_class_symbol(&class_id) + { + &class_sym.url + } else { + return oref_method_refs; + }; + let Some(document) = self.get_document(url) else { + eprintln!( + "Error: failed to get document for file {:?}. Aborting get_variable_definition", + url.path() + ); + return oref_method_refs; + }; + + let Some(curr_method_name) = document.scope_tree.get_method_name(point) else { + return oref_method_refs; + }; + + if let Some(method_ref) = self + .method_defs + .get(curr_class) + .and_then(|method_refs| method_refs.get(&curr_method_name)) + { + let Some(lsm) = self + .global_semantic_model + .get_local_semantic(&method_ref.class) + else { + return oref_method_refs; + }; + let method = if let Some(m) = self.global_semantic_model.get_method(&method_ref) { + m + } else if let Some(m) = lsm.get_method(&method_ref) { + m + } else { + return oref_method_refs; + }; + // true if variable is defined in current method + if let Some(variable_refs) = method.variables.get(oref_name) { + for var_ref in variable_refs { + if let Some(var_id) = var_ref.pub_id { + if let Some(variable) = self + .global_semantic_model + .get_variable(method_ref, var_id.0) + && variable.is_oref + { + if let Some(oref_class) = &variable.cls { + if let Some(oref_method_ref) = self + .method_defs + .get(oref_class) + .and_then(|methods| methods.get(oref_method_name)) + { + oref_method_refs.push(*oref_method_ref) + } + } + } + } else if let Some(var_id) = var_ref.priv_id { + if let Some(variable) = lsm.get_variable(method_ref, var_id.0) + && variable.is_oref + { + if let Some(oref_class) = &variable.cls { + if let Some(oref_method_ref) = self + .method_defs + .get(oref_class) + .and_then(|methods| methods.get(oref_method_name)) + { + oref_method_refs.push(*oref_method_ref) + } + } + } + } + } + return oref_method_refs; + } else if self.is_variable_public(*method_ref, oref_method_name.to_string()) { + if let Some(&node_index) = self.dependency_graph.get_node(*method_ref) + && let Some(public_var_definitions) = self.pub_var_defs.get(oref_name) + { + let all_potential_nodes_on_path = + self.dependency_graph.all_ancestors(node_index); + for (def_method_ref, variable_refs) in public_var_definitions { + if all_potential_nodes_on_path.contains(def_method_ref) { + for variable_ref in variable_refs { + if let Some(var_id) = variable_ref.pub_id + && let Some(variable) = self + .global_semantic_model + .get_variable(def_method_ref, var_id.0) + && variable.is_oref + { + if let Some(oref_class) = &variable.cls { + if let Some(oref_method_ref) = self + .method_defs + .get(oref_class) + .and_then(|methods| methods.get(oref_method_name)) + { + oref_method_refs.push(*oref_method_ref) + } + } + } + } + } + } + } + } + } + oref_method_refs + } + + fn get_method_calls( + &self, + method_def_node: Node, + content: &str, + language: &TsLanguage, + query_str: &str, + curr_class: &str, + ) -> Vec { + let mut method_refs = Vec::new(); + if let Ok(query) = Query::new(language, query_str) { + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(&query, method_def_node, content.as_bytes()); + while let Some(query_match) = iter.next() { + let matched_node = query_match.captures[0].node; + match matched_node.kind() { + "class_method_call" => { + if let Some(class_ref) = matched_node.named_child(0) + && let Some(method_name_node) = matched_node.named_child(1) + && let Some(class_name_node) = class_ref.named_child(1) + { + // this part will remove the strings and such (it grabs the actual $.identifier node) + if let Some(method_name) = method_name_node.named_child(0) + && let Some(class_name) = class_name_node.named_child(0) + { + if let Some(method_name) = + get_string_at_byte_range(content, method_name.byte_range()) + && let Some(class_name) = + get_string_at_byte_range(content, class_name.byte_range()) + { + if let Some(method_ref) = self + .method_defs + .get(&class_name) + .and_then(|method_refs| method_refs.get(&method_name)) + { + method_refs.push(*method_ref); + } + } + } + } else { + eprintln!( + "Error: Expected child at index 0 for class_method_call node, and expected child at index 1 for class_ref" + ); + }; + } + "system_defined_function" => { + let Some(node_str) = + get_string_at_byte_range(content, matched_node.byte_range()) + else { + continue; + }; + let (before, method_args) = ( + node_str.split('(').nth(0), + node_str.split('(').nth(1).unwrap_or(""), + ); + if let Some(func_name) = before { + if func_name.eq_ignore_ascii_case("$zobjmethod") + || func_name.eq_ignore_ascii_case("$method") + { + // instance name is first for $method + let Some(oref_node) = matched_node.named_child(0) else { + eprintln!( + "Error: System Defined Variable has no child at index 0" + ); + continue; + }; + let Some(instance_name) = + get_identifier_from_method_arg(oref_node, content) + else { + continue; + }; + + let Some(method_name_node) = matched_node.named_child(1) else { + eprintln!( + "Error: System Defined Variable has no child at index 1" + ); + continue; + }; + if let Some(method_name) = + get_identifier_from_method_arg(method_name_node, content) + { + method_refs.extend(self.find_classes_from_oref( + &instance_name, + &method_name, + &curr_class, + oref_node.start_position(), + )); + } else { + eprintln!("Error: Couldn't get method name from $CLASSMETHOD"); + } + } else if func_name.eq_ignore_ascii_case("$classmethod") + || func_name.eq_ignore_ascii_case("$zobjclassmethod") + { + let method_node; + let class_name; + + if method_args.trim_start().chars().next() == Some(',') { + // class is current one + method_node = matched_node.named_child(0); + class_name = curr_class.to_string(); + } else { + method_node = matched_node.named_child(1); + let Some(class_name_node) = matched_node.named_child(0) else { + // this should be a method arg + eprintln!( + "Error: Expected system defined function to have a child at index 0" + ); + continue; + }; + let Some(cls_name) = + get_identifier_from_method_arg(class_name_node, content) + else { + continue; + }; + class_name = cls_name; + } + let Some(method_arg) = method_node else { + continue; + }; + if let Some(method_name) = + get_identifier_from_method_arg(method_arg, content) + { + if let Some(method_ref) = self + .method_defs + .get(&class_name) + .and_then(|method_refs| method_refs.get(&method_name)) + { + method_refs.push(*method_ref); + } + } + } else if func_name.eq_ignore_ascii_case("$system") { + if let Some(class_name_node) = matched_node.named_child(0) + && let Some(method_name_node) = matched_node.named_child(1) + { + let Some(class_name) = get_string_at_byte_range( + content, + class_name_node.byte_range(), + ) else { + continue; + }; + let Some(method_name) = get_string_at_byte_range( + content, + method_name_node.byte_range(), + ) else { + continue; + }; + if let Some(method_ref) = self + .method_defs + .get(&class_name) + .and_then(|method_refs| method_refs.get(&method_name)) + { + method_refs.push(*method_ref); + } + } + } + } + } + "relative_dot_method" => { + if let Some(oref_method) = matched_node.named_child(0) + && let Some(method_name_node) = oref_method.named_child(0) + && let Some(method_identifier) = method_name_node.named_child(0) + { + let Some(method_name) = + get_string_at_byte_range(content, method_identifier.byte_range()) + else { + continue; + }; + if let Some(method_ref) = self + .method_defs + .get(curr_class) + .and_then(|method_refs| method_refs.get(&method_name)) + { + method_refs.push(*method_ref); + } + } + } + "routine_tag_call" | "goto_argument" | "print_argument" | "zbreak_location" => { + let Some(routine_tag_call_child) = matched_node.named_child(0) else { + eprintln!( + "Error: routine tag call node should have a child at index 0, update parsing in get_method_calls" + ); + continue; + }; + + match routine_tag_call_child.kind() { + "method_name" => { + // this version doesn't have wrapped in quotes option + let Some(method_name) = + get_string_at_byte_range(content, matched_node.byte_range()) + else { + continue; + }; + if let Some(method_ref) = self + .method_defs + .get(curr_class) + .and_then(|method_refs| method_refs.get(&method_name)) + { + method_refs.push(*method_ref); + } + } + "line_ref" => { + let (method_name, routine_name, offset) = parse_line_ref( + routine_tag_call_child, + content, + curr_class.to_string(), + ); + + if let Some(method_ref) = self + .method_defs + .get(&routine_name) + .and_then(|method_refs| method_refs.get(&method_name)) + { + let method_ref = MethodRef { + class: method_ref.class, + id: method_ref.id, + offset: offset, + }; + method_refs.push(method_ref); + } + } + _ => continue, + } + } + + _ => continue, + } + } + } + method_refs + } + + // Parses a method definition node to extract it's dependencies for the given method. + // Adds edges from the method to it's dependencies in the dependency graph + fn find_method_dependencies( + &mut self, + node: Node, + content: &str, + language: &TsLanguage, + curr_class: &str, + method_ref: &MethodRef, + ) { + // Vec + // first, find all class method definitions + let query_str = "(class_method_call) @classmethodcall"; + let mut method_refs = self.get_method_calls(node, content, language, query_str, curr_class); + + let query_str = "(system_defined_function) @systemfunc"; + + method_refs.extend(self.get_method_calls(node, content, language, query_str, curr_class)); + + let query_str = "(relative_dot_method) @relativemethod"; + method_refs.extend(self.get_method_calls(node, content, language, query_str, curr_class)); + + let query_str = r#"[ + (routine_tag_call) + (goto_argument) + (print_argument) + (zbreak_location) + ] @routine "#; + method_refs.extend(self.get_method_calls(node, content, language, query_str, curr_class)); + for dep_method_ref in method_refs { + self.dependency_graph + .add_edge(method_ref.clone(), dep_method_ref); + } + } +} + +impl ProjectState { + /// Create a new `ProjectState` with default configuration and empty indexing state. + /// + /// Initializes shared parsers, an empty `ProjectData` store, and leaves `project_root_path` + /// unset (expected to be populated during LSP initialization). + pub fn new() -> Self { + Self { + project_root_path: OnceLock::new(), + parsers: WorkspaceParsers::new(), + data: RwLock::new(ProjectData { + config: Config::default(), + documents: HashMap::new(), + global_semantic_model: GlobalSemanticModel::new(), + classes: HashMap::new(), + method_defs: HashMap::new(), + pub_var_defs: HashMap::new(), + override_index: OverrideIndex::new(), + dependent_class_index: Dependents::new(), + dependency_graph: DependencyGraph::new(), + }), + } + } + + /// Handle an LSP `textDocument/didOpen` by parsing and committing the document. + /// + /// Parses the text with the appropriate Tree-sitter grammar, derives the class name for `.cls` + /// files, then updates project state inside a single write lock: + /// - Adds the document if new, or updates it if contents/type changed + /// - Rebuilds inheritance/override/call/variable indexes for affected state + pub fn handle_document_opened( + &self, + url: Url, + text: String, + file_type: FileType, + version: i32, + ) { + // Parse OUTSIDE lock + let tree = match file_type { + FileType::Cls => match self.parsers.cls.lock().parse(&text, None) { + Some(t) => t, + None => { + eprintln!("parse failed for cls file with content: {}", text); + generic_exit_statements("ProjectState", "handle_document_opened"); + return; + } + }, + FileType::Routine => match self.parsers.routine.lock().parse(&text, None) { + Some(t) => t, + None => { + eprintln!("parse failed for routine file with content: {}", text); + generic_exit_statements("ProjectState", "handle_document_opened"); + return; + } + }, + FileType::Xml => match self.parsers.xml.lock().parse(&text, None) { + Some(t) => t, + None => { + eprintln!("parse failed for xml file with content: {}", text); + generic_exit_statements("ProjectState", "handle_document_opened"); + return; + } + }, + }; + + if file_type == FileType::Xml { + let mut data = self.data.write(); + let existing_snapshot = data + .documents + .get(&url) + .map(|d| (d.content.clone(), d.file_type.clone())); + + match existing_snapshot { + None => { + data.add_document(url, text, tree, file_type, None, Some(version)); + } + Some((old_text, old_type)) => { + if old_text != text || old_type != file_type { + data.update_document(url, tree, file_type, version, &text); + } else if let Some(doc) = data.documents.get_mut(&url) { + doc.version = Some(version); + } + } + } + return; + } else if file_type == FileType::Routine || file_type == FileType::Cls { + let is_rtn = if file_type == FileType::Routine { + true + } else { + false + }; + + let Some(member_name) = get_member_name_from_root(&text, tree.root_node(), is_rtn) + else { + eprintln!( + "Error: Failed to get name from root node for file url: {:?}", + url.path() + ); + return; + }; + // Commit INSIDE one lock + let mut data = self.data.write(); + + let existing_snapshot = data + .documents + .get(&url) + .map(|d| (d.content.clone(), d.file_type.clone())); + + match existing_snapshot { + None => { + data.add_document( + url.clone(), + text, + tree, + file_type, + Some(member_name), + Some(version), + ); + // build override index/calls/vars for new doc too + data.build_inheritance_and_variables(Some(url), Vec::new()); + } + Some((old_text, old_type)) => { + if old_text != text || old_type != file_type { + data.update_document(url, tree, file_type, version, &text); + } else { + if let Some(doc) = data.documents.get_mut(&url) { + doc.version = Some(version); + } + } + } + } + } + } + + /// Wrapper to read document info from the inner `ProjectData`. + pub fn get_document_info(&self, url: &Url) -> Option<(FileType, String, i32, Tree)> { + self.data.read().get_document_info(url) + } + + /// Wrapper to update a document inside the inner `ProjectData` + pub fn update_document( + &self, + url: Url, + tree: Tree, + file_type: FileType, + version: i32, + content: &str, + ) { + self.data + .write() + .update_document(url, tree, file_type, version, content); + } + + /// Wrapper to refactor a document inside the inner `ProjectData` + pub fn refactor_document(&self, url: &Url, refactor_level: RefactorLevel) -> Option { + self.data.read().refactor_document(url, refactor_level) + } + + /// Wrapper to refactor a workspace inside the inner `ProjectData` + pub fn refactor(&self, refactor_level: RefactorLevel) -> Vec<(String, Url)> { + self.data.read().refactor(refactor_level) + } + + /// Return the project root path, if initialized. + pub fn root_path(&self) -> Option<&std::path::Path> { + self.project_root_path.get().and_then(|o| o.as_deref()) + } +} diff --git a/objectscript-lsp/objectscript-tests/dependencies/method-call-deps.cls b/objectscript-lsp/objectscript-tests/dependencies/method-call-deps.cls new file mode 100644 index 0000000..4d13860 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/dependencies/method-call-deps.cls @@ -0,0 +1,18 @@ +Class Demo.MethodCallDependencies Extends %RegisteredObject +{ + +Method CaptureCalls() As %Status +{ + Do ##class(External.ClassOne).Run(1) + Set sc = $classmethod("External.ClassTwo","Ping",2) + Set sc2 = $method("External.ClassThree","Pong",3) + Do ..LocalSelf(4) + Quit 1 +} + +Method LocalSelf(x As %Integer = 0) As %Status +{ + Quit 1 +} + +} diff --git a/objectscript-lsp/objectscript-tests/dependencies/oref-usage-deps.cls b/objectscript-lsp/objectscript-tests/dependencies/oref-usage-deps.cls new file mode 100644 index 0000000..4be33e5 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/dependencies/oref-usage-deps.cls @@ -0,0 +1,18 @@ +Class Demo.OrefUsageDependencies [ Not ProcedureBlock, Language = objectscript ] +{ + +Method BuildAndUsePublic() { + Set obj = ##class(External.PublicOrefClass).%New() + Do obj.Run() +} + +Method BuildAndUsePrivate() [ ProcedureBlock = 1 ] { + Set pObj = ##class(External.PrivateOrefClass).%New() + Do pObj.Ping() +} + +Method DefineButUnused() { + Set neverUsed = ##class(External.UnusedOrefClass).%New() + Quit +} +} diff --git a/objectscript-lsp/objectscript-tests/dependencies/queries.cls b/objectscript-lsp/objectscript-tests/dependencies/queries.cls new file mode 100644 index 0000000..a36face --- /dev/null +++ b/objectscript-lsp/objectscript-tests/dependencies/queries.cls @@ -0,0 +1,95 @@ +Class Demo.QueryExamples Extends %RegisteredObject +{ + +/// 1) Basic class query (%SQLQuery) +Query EmployeesByCity(pCity As %String = "") + As %SQLQuery(ROWSPEC="ID:%Integer,Name:%String,Dept:%String",CONTAINID=1) + [ SqlProc, SqlName = Demo_EmployeesByCity ] +{ +SELECT ID, Name, Department AS Dept +FROM Demo.Employee +WHERE (:pCity = '' OR City %STARTSWITH :pCity) +ORDER BY Name +} + +/// 2) Custom class query (%Query + Execute/Fetch/Close) +Query ActivePatients(minAge As %Integer = 0) + As %Query(ROWSPEC="ID:%Integer,Name:%String,Age:%Integer",CONTAINID=1) +{ +} + +ClassMethod ActivePatientsExecute(ByRef qHandle As %Binary, minAge As %Integer = 0) As %Status +{ + Set stmt=##class(%SQL.Statement).%New() + Set sql="SELECT ID,Name,Age FROM Demo.Patient WHERE Age >= ? ORDER BY Name" + Set sc=stmt.%Prepare(sql) + If sc'=1 Quit sc + Set qHandle=stmt.%Execute(minAge) + Quit 1 +} + +ClassMethod ActivePatientsFetch( + ByRef qHandle As %Binary, + ByRef Row As %List, + ByRef AtEnd As %Integer = 0 +) As %Status [ PlaceAfter = ActivePatientsExecute ] +{ + Set rs=$Get(qHandle) + If rs="" { + Set Row="",AtEnd=1 + Quit 1 + } + + If rs.%Next() { + Set Row=$ListBuild(rs.%Get("ID"),rs.%Get("Name"),rs.%Get("Age")) + Set AtEnd=0 + } Else { + Set Row="",AtEnd=1 + } + + Quit 1 +} + +ClassMethod ActivePatientsClose(ByRef qHandle As %Binary) As %Status [ PlaceAfter = ActivePatientsFetch ] +{ + Set qHandle="" + Quit 1 +} + +/// 3) Embedded SQL example (&sql with cursor) +ClassMethod EmbeddedSqlCursor(state As %String = "MA") As %Status +{ + New SQLCODE,%ROWCOUNT,name,dob + + &sql(DECLARE C1 CURSOR FOR + SELECT Name, DOB + FROM Demo.Patient + WHERE HomeState = :state + ORDER BY Name) + + &sql(OPEN C1) + + For { + &sql(FETCH C1 INTO :name,:dob) + Quit:SQLCODE'=0 + Write !,name," | ",dob + } + + &sql(CLOSE C1) + Quit 1 +} + +/// 4) SQL Gateway style SQL via dynamic SQL text +ClassMethod SQLGatewayExample(city As %String = "Boston") As %Status +{ + Set ddlServer = "CREATE FOREIGN SERVER Demo.ExtDB FOREIGN DATA WRAPPER JDBC CONNECTION 'PostgresConnection'" + Set ddlTable = "CREATE FOREIGN TABLE Demo.ExtPatients (PatientID BIGINT, Name VARCHAR(100), City VARCHAR(50)) SERVER Demo.ExtDB TABLE 'public.patients'" + Set query = "SELECT PatientID,Name FROM Demo.ExtPatients WHERE City = ?" + + Set stmt = ##class(%SQL.Statement).%New() + Set sc = stmt.%Prepare(query) + If sc'=1 Quit sc + Set rs = stmt.%Execute(city) + Quit 1 +} +} diff --git a/objectscript-lsp/objectscript-tests/dotted-block/test-dotted-block-actual.mac b/objectscript-lsp/objectscript-tests/dotted-block/test-dotted-block-actual.mac new file mode 100644 index 0000000..b2bd736 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/dotted-block/test-dotted-block-actual.mac @@ -0,0 +1,22 @@ +ROUTINE test + +dotted + if 1 do dottedSubroutine1 +#; comment + +dottedSubroutine1 + if 1 { + w "hi" + } + quit + +dottedComment + do dottedCommentSubroutine1 + +dottedCommentSubroutine1 + /*w hi*/ + set x = 1 + if x { + w "bye" + } + quit diff --git a/objectscript-lsp/objectscript-tests/dotted-block/test-dotted-block-expected.mac b/objectscript-lsp/objectscript-tests/dotted-block/test-dotted-block-expected.mac new file mode 100644 index 0000000..b2bd736 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/dotted-block/test-dotted-block-expected.mac @@ -0,0 +1,22 @@ +ROUTINE test + +dotted + if 1 do dottedSubroutine1 +#; comment + +dottedSubroutine1 + if 1 { + w "hi" + } + quit + +dottedComment + do dottedCommentSubroutine1 + +dottedCommentSubroutine1 + /*w hi*/ + set x = 1 + if x { + w "bye" + } + quit diff --git a/objectscript-lsp/objectscript-tests/dotted-block/test-dotted-block.mac b/objectscript-lsp/objectscript-tests/dotted-block/test-dotted-block.mac new file mode 100644 index 0000000..f0b5cc9 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/dotted-block/test-dotted-block.mac @@ -0,0 +1,17 @@ +ROUTINE test + +dotted + if 1 d + . if 1 { + . w "hi" + . } +#; comment + + +dottedComment + do + . /*w hi*/ + . set x = 1 + . if x { + . w "bye" + . } diff --git a/objectscript-lsp/objectscript-tests/gotodef/goto-oref-calls.cls b/objectscript-lsp/objectscript-tests/gotodef/goto-oref-calls.cls new file mode 100644 index 0000000..413e9c0 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/gotodef/goto-oref-calls.cls @@ -0,0 +1,10 @@ +Class Testing { + Method CreateOref() { + set x = ##class(Demo.Person).%New(); + d ..FindOref() + } + + Method FindOref() [ProcedureBlock=0] { + d x.Introduce() + } +} diff --git a/objectscript-lsp/objectscript-tests/gotodef/oref-instance.cls b/objectscript-lsp/objectscript-tests/gotodef/oref-instance.cls new file mode 100644 index 0000000..1cc190c --- /dev/null +++ b/objectscript-lsp/objectscript-tests/gotodef/oref-instance.cls @@ -0,0 +1,25 @@ +Class Demo.Person Extends %RegisteredObject +{ + Property Name As %String; + + Property Age As %Integer; + + ClassMethod %New() { + + } + + Method Introduce() As %String + { + Quit "Hi, my name is "_..Name_" and I am "_..Age_" years old." + } + + ClassMethod Example() + { + Set person = ##class(Demo.Person).%New() + + Set person.Name = "Hannah" + Set person.Age = 25 + + Write person.Introduce(), ! + } +} diff --git a/objectscript-lsp/objectscript-tests/gotodef/test-routine-goto.mac b/objectscript-lsp/objectscript-tests/gotodef/test-routine-goto.mac new file mode 100644 index 0000000..e0b6114 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/gotodef/test-routine-goto.mac @@ -0,0 +1,11 @@ +ROUTINE goto + +gotosubroutine + new x + set x = 1.2 + set y = 1.2 + w !,"goodbye" + w x + quit + set x = ##class(Demo.Person).%New(); + d x.Introduce() diff --git a/objectscript-lsp/objectscript-tests/inheritance/class-keyword-inheritance.cls b/objectscript-lsp/objectscript-tests/inheritance/class-keyword-inheritance.cls new file mode 100644 index 0000000..bd76f57 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/inheritance/class-keyword-inheritance.cls @@ -0,0 +1,19 @@ +Class TestingClassKeywordInheritance.c [Not ProcedureBlock, Language = objectscript, inheritance=right] +{ + Method newVarChange() [ProcedureBlock=1] { + set x = 2 + w x + } + + ClassMethod multiplePubVarDefs() { + set y = 24 + w x + } + + Method testingIndentation() { + set x = 3 + w x + + } + +} diff --git a/objectscript-lsp/objectscript-tests/keywords/class-keywords.cls b/objectscript-lsp/objectscript-tests/keywords/class-keywords.cls new file mode 100644 index 0000000..d82da85 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/keywords/class-keywords.cls @@ -0,0 +1,15 @@ +Class Test.LanguageObjectScript [ Language = objectscript, inheritance = right, procedureBlock ] +{ + Method pythonTesting() [procedureBlock=0,PublicList=(x,y,z), Private, Language = python] + { + + } + + Method codeModeExpressionTesting() [codeMode = expression] { + x.y + } + + Method testingCode() [CodeMode=code] { + + } +} diff --git a/objectscript-lsp/objectscript-tests/navigation/implementation/sub_private.cls b/objectscript-lsp/objectscript-tests/navigation/implementation/sub_private.cls new file mode 100644 index 0000000..51195e3 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/navigation/implementation/sub_private.cls @@ -0,0 +1,7 @@ +Class Demo.NavSubPrivate Extends Demo.NavSuper +{ +Method Overridden() [ Private ] As %Status +{ + Quit 1 +} +} diff --git a/objectscript-lsp/objectscript-tests/navigation/implementation/sub_public.cls b/objectscript-lsp/objectscript-tests/navigation/implementation/sub_public.cls new file mode 100644 index 0000000..d6029a3 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/navigation/implementation/sub_public.cls @@ -0,0 +1,7 @@ +Class Demo.NavSubPublic Extends Demo.NavSuper +{ +Method Overridden() As %Status +{ + Quit 1 +} +} diff --git a/objectscript-lsp/objectscript-tests/navigation/implementation/super.cls b/objectscript-lsp/objectscript-tests/navigation/implementation/super.cls new file mode 100644 index 0000000..08b8a1d --- /dev/null +++ b/objectscript-lsp/objectscript-tests/navigation/implementation/super.cls @@ -0,0 +1,12 @@ +Class Demo.NavSuper +{ +Method Overridden() As %Status +{ + Quit 1 +} + +Method NoOverride() As %Status +{ + Quit 1 +} +} diff --git a/objectscript-lsp/objectscript-tests/nested_dots/test-nested-refactor-actual.mac b/objectscript-lsp/objectscript-tests/nested_dots/test-nested-refactor-actual.mac new file mode 100644 index 0000000..57eb371 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/nested_dots/test-nested-refactor-actual.mac @@ -0,0 +1,16 @@ +ROUTINE x + +test + do testSubroutine1 + q +#; + +testSubroutine1 + set x = "hi" + if x = "bye" do testSubroutine2 + w "goodbye" + quit + +testSubroutine2 + w "Bye" + quit diff --git a/objectscript-lsp/objectscript-tests/nested_dots/test-nested-refactor-expected.mac b/objectscript-lsp/objectscript-tests/nested_dots/test-nested-refactor-expected.mac new file mode 100644 index 0000000..57eb371 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/nested_dots/test-nested-refactor-expected.mac @@ -0,0 +1,16 @@ +ROUTINE x + +test + do testSubroutine1 + q +#; + +testSubroutine1 + set x = "hi" + if x = "bye" do testSubroutine2 + w "goodbye" + quit + +testSubroutine2 + w "Bye" + quit diff --git a/objectscript-lsp/objectscript-tests/nested_dots/test-nested-refactor.mac b/objectscript-lsp/objectscript-tests/nested_dots/test-nested-refactor.mac new file mode 100644 index 0000000..ea1d866 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/nested_dots/test-nested-refactor.mac @@ -0,0 +1,10 @@ +ROUTINE x + +test + d + . set x = "hi" + . if x = "bye" d + . . w "Bye" + . w "goodbye" + q +#; diff --git a/objectscript-lsp/objectscript-tests/routines/test-refactor-do-actual.mac b/objectscript-lsp/objectscript-tests/routines/test-refactor-do-actual.mac new file mode 100644 index 0000000..464dd04 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/routines/test-refactor-do-actual.mac @@ -0,0 +1,78 @@ +ROUTINE test + +subroutine + new x + set x = 1.2 + set y = 1.2 + w !,"goodbye" + quit + +subroutine2 + set x = 250 + w !, x + do check + +proc() [z] public { + set z = 10 +} + +check2() + set x = 2 + set y = 5 + i x = 2 do check2Subroutine1 + +check2Subroutine1 + set x = 1.2 + set y = 1.2 + w !,"this code does execute" + quit + +check() private + set x = 2 + set y = 5 + i x = 2 do checkSubroutine1 d okay set x = 2 + w !,"again x=",x + w !,"y=",y + w !,"leaving" + quit + +checkSubroutine1 + set x = 1.2 + set y = 1.2 + w !,"goodbye" + do checkSubroutine2 + quit + +checkSubroutine2 + new x + set x = 250 + w !, x + quit + +potentialS() + set x = 2 + set y = 5 + do proc + w !,"again x=",x + w !,"y=",y + w !,"leaving" + w !, "z= ", z + quit + +same() + set x = 2 + set y = 5 + i x = 2 d subroutine, okay + w !,"again x=",x + w !,"y=",y + w !,"leaving" + quit + +okay + new y + set y = -9 + if + w !,"here" + if w !,"okay" + set x = 1.1 + w !,1 diff --git a/objectscript-lsp/objectscript-tests/routines/test-refactor-do-expected.mac b/objectscript-lsp/objectscript-tests/routines/test-refactor-do-expected.mac new file mode 100644 index 0000000..2718412 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/routines/test-refactor-do-expected.mac @@ -0,0 +1,78 @@ +ROUTINE test + +subroutine + new x + set x = 1.2 + set y = 1.2 + w !,"goodbye" + quit + +subroutine2 + set x = 250 + w !, x + do check + +proc() [z] public { + set z = 10 +} + +check2() + set x = 2 + set y = 5 + i x = 2 d check2Subroutine + +check2Subroutine1 + set x = 1.2 + set y = 1.2 + w !,"this code does execute" + quit + +check() private + set x = 2 + set y = 5 + i x = 2 d checkSubroutine1 d okay set x = 2 + w !,"again x=",x + w !,"y=",y + w !,"leaving" + quit + +checkSubroutine1 + set x = 1.2 + set y = 1.2 + w !,"goodbye" + do checkSubroutine2 + quit + +checkSubroutine2 + new x + set x = 250 + w !, x + quit + +potentialS() + set x = 2 + set y = 5 + do proc + w !,"again x=",x + w !,"y=",y + w !,"leaving" + w !, "z= ", z + quit + +same() + set x = 2 + set y = 5 + i x = 2 d subroutine, okay + w !,"again x=",x + w !,"y=",y + w !,"leaving" + quit + +okay + new y + set y = -9 + if + w !,"here" + if w !,"okay" + set x = 1.1 + w !,1 diff --git a/objectscript-lsp/objectscript-tests/routines/test-refactor-do.mac b/objectscript-lsp/objectscript-tests/routines/test-refactor-do.mac new file mode 100644 index 0000000..d38c6a0 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/routines/test-refactor-do.mac @@ -0,0 +1,69 @@ +ROUTINE test + +subroutine + new x + set x = 1.2 + set y = 1.2 + w !,"goodbye" + quit + +subroutine2 + set x = 250 + w !, x + do check + +proc() [z] public { + set z = 10 +} +check2() + set x = 2 + set y = 5 + i x = 2 d + . set x = 1.2 + . set y = 1.2 + . w !,"this code does execute" + +check() private + set x = 2 + set y = 5 + i x = 2 d d okay set x = 2 + . set x = 1.2 + . set y = 1.2 + . w !,"goodbye" d + . . new x + . . set x = 250 + . . w !, x + . quit + w !,"again x=",x + w !,"y=",y + w !,"leaving" + quit + +potentialS() + set x = 2 + set y = 5 + do proc + w !,"again x=",x + w !,"y=",y + w !,"leaving" + w !, "z= ", z + quit + + +same() + set x = 2 + set y = 5 + i x = 2 d subroutine, okay + w !,"again x=",x + w !,"y=",y + w !,"leaving" + quit + +okay + new y + set y = -9 + if + w !,"here" + if w !,"okay" + set x = 1.1 + w !,1 diff --git a/objectscript-lsp/objectscript-tests/routines/test-testing.mac b/objectscript-lsp/objectscript-tests/routines/test-testing.mac new file mode 100644 index 0000000..15af3f0 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/routines/test-testing.mac @@ -0,0 +1,16 @@ +ROUTINE x + +test + do testSubroutine1 + q +#; + +testSubroutine1 + set x = "hi" + if x = "bye" do testSubroutine2 + quit + +testSubroutine2 + w "Bye" + w "goodbye" + quit diff --git a/objectscript-lsp/objectscript-tests/variables/subclass.cls b/objectscript-lsp/objectscript-tests/variables/subclass.cls new file mode 100644 index 0000000..a0ce75e --- /dev/null +++ b/objectscript-lsp/objectscript-tests/variables/subclass.cls @@ -0,0 +1,7 @@ +Class SubClassOne Extends SuperClass { + Method newVarChange() { + set x = 7 + w x + d ##class(SuperClass).multiplePubVarDefs() + } +} diff --git a/objectscript-lsp/objectscript-tests/variables/subclass_two.cls b/objectscript-lsp/objectscript-tests/variables/subclass_two.cls new file mode 100644 index 0000000..0707b97 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/variables/subclass_two.cls @@ -0,0 +1,6 @@ +Class SubClassTwo Extends SuperClass { + Method newVarChange() { + set x = 10 + w x + } +} diff --git a/objectscript-lsp/objectscript-tests/variables/testing-variable-building.cls b/objectscript-lsp/objectscript-tests/variables/testing-variable-building.cls new file mode 100644 index 0000000..4c8d0c6 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/variables/testing-variable-building.cls @@ -0,0 +1,22 @@ +Class SuperClass [Not ProcedureBlock, Language = objectscript] +{ + Method newVarChange() { + set x = 32 + w x + w y + d ..multiplePubVarDefs() + } + + ClassMethod multiplePubVarDefs() { + set y = 24 + w x + w y + write "Goodbye" + } + + Method testingIndentation() { + set y = 3 + set x = 12 + w y + } +} diff --git a/objectscript-lsp/src/backend_testing.rs b/objectscript-lsp/src/backend_testing.rs new file mode 100644 index 0000000..97cb1b5 --- /dev/null +++ b/objectscript-lsp/src/backend_testing.rs @@ -0,0 +1,172 @@ +use objectscript_core::common::get_member_name_from_root; +use objectscript_core::parse_structures::FileType; +use objectscript_core::workspace::ProjectState; +use parking_lot::RwLock; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tower_lsp::lsp_types::Url; +use tree_sitter::Parser; +use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; +use tree_sitter_objectscript_routine::LANGUAGE_OBJECTSCRIPT_ROUTINE; +use walkdir::WalkDir; + +/// Test harness that mirrors the real Backend for integration testing without a live LSP client. +#[derive(Debug)] +pub(crate) struct BackendTester { + pub(crate) projects: Arc>>>, +} + +impl BackendTester { + /// Create a new empty BackendTester with no registered projects. + pub(crate) fn new() -> Self { + Self { + projects: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Register a workspace project by URI. + pub(crate) fn add_project(&self, uri: Url, state: ProjectState) { + self.projects.write().insert(uri, Arc::new(state)); + } + + /// Retrieve a project by its workspace URI. + pub fn get_project(&self, uri: &Url) -> Option> { + self.projects.read().get(uri).cloned() + } + + fn find_parent_workspace(&self, uri: Url) -> Option { + let doc_path: PathBuf = uri.to_file_path().ok()?; + + // find longest prefix + let projects = self.projects.read(); + + projects + .keys() + .filter_map(|ws_uri| { + let ws_path = ws_uri.to_file_path().ok()?; + if doc_path.starts_with(&ws_path) { + Some((ws_path.components().count(), ws_uri.clone())) + } else { + None + } + }) + .max_by_key(|(depth, _)| *depth) + .map(|(_, ws_uri)| ws_uri) + } + + /// Resolve the project that contains the given document URI. + pub(crate) fn get_project_from_document_url(&self, uri: &Url) -> Option> { + let project_url = self.find_parent_workspace(uri.clone())?; + self.get_project(&project_url) + } + + /// Parse and index all ObjectScript files under the workspace containing `uri`. + pub(crate) async fn index_workspace(&self, uri: &Url) { + let Some(project) = self.get_project_from_document_url(&uri) else { + return; + }; + let Some(root) = project.root_path() else { + eprintln!("Couldn't get root"); + return; + }; + let root = root.to_path_buf(); + // Run indexing on Tokio's blocking thread pool + let handle = tokio::task::spawn_blocking(move || { + let mut cls_parser = Parser::new(); + if cls_parser + .set_language(&LANGUAGE_OBJECTSCRIPT_UDL.into()) + .is_err() + { + eprintln!("Failed to load ObjectScript UDL grammar"); + return; + } + + let mut routine_parser = Parser::new(); + if routine_parser + .set_language(&LANGUAGE_OBJECTSCRIPT_ROUTINE.into()) + .is_err() + { + eprintln!("Failed to load ObjectScript routine grammar"); + return; + } + let mut documents_already_existing = Vec::new(); + for entry in WalkDir::new(&root).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + + let Some(ext) = path.extension().and_then(|s| s.to_str()) else { + continue; + }; + + let (filetype, use_routine) = match ext { + "cls" => (FileType::Cls, false), + "inc" => (FileType::Routine, true), + "rtn" => (FileType::Routine, true), + "mac" => (FileType::Routine, true), + "int" => (FileType::Routine, true), + _ => continue, + }; + + let code = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(_) => continue, + }; + + let url = match Url::from_file_path(path) { + Ok(u) => u, + Err(_) => continue, + }; + + let tree = if use_routine { + match routine_parser.parse(&code, None) { + Some(t) => t, + None => { + eprintln!("Failed to parse file: {:?}", path); + continue; + } + } + } else { + match cls_parser.parse(&code, None) { + Some(t) => t, + None => { + eprintln!("Failed to parse file: {:?}", path); + continue; + } + } + }; + let is_rtn = if filetype == FileType::Routine { + true + } else { + false + }; + + let member_name = + get_member_name_from_root(code.as_str(), tree.root_node(), is_rtn); + + // Commit inside the ProjectData lock + { + let mut data = project.data.write(); + let already_exists = data.add_document_if_absent( + url.clone(), + code, + tree, + filetype, + member_name, + None, + ); + if already_exists { + documents_already_existing.push(url); + } + } + } + { + let mut data = project.data.write(); + data.build_inheritance_and_variables(None, documents_already_existing); + } + }); + // Wait for completion (and handle join errors) + if let Err(join_err) = handle.await { + eprintln!("index_workspace_scope spawn_blocking failed: {join_err:?}"); + } + } +} diff --git a/objectscript-lsp/src/common.rs b/objectscript-lsp/src/common.rs new file mode 100644 index 0000000..2a7706f --- /dev/null +++ b/objectscript-lsp/src/common.rs @@ -0,0 +1,67 @@ +use objectscript_core::common::get_node_children; +use tree_sitter::Node; +/// Generate a human-readable diagnostic message for a syntax error node. +pub fn diagnostic_message(node: Node, error_text: &str) -> Option { + if let Some(sibling_node) = node.prev_named_sibling() { + match sibling_node.kind() { + "statement" => { + let child = sibling_node.named_child(0); + if let Some(child) = child { + match child.kind() { + "command_set" => { + let children = get_node_children(child); + if let Some(last_child) = children.last() { + match last_child.kind() { + "keyword_set" => { + let Some(_) = node.parent() else { + return Some(format!( + "Syntax Error: Invalid set command {}", + error_text + )); + }; + return Some(format!( + "Syntax Error: Expected a variable name, got {}", + error_text + )); + } + "set_argument" => { + let set_arg_children = + get_node_children(last_child.clone()); + if let Some(child) = set_arg_children.last() { + match child.kind() { + "set_target" | "set_target_list" => { + if let Some(next_sib) = child.next_sibling() { + if next_sib.kind() == "=" { + return Some(format!("Syntax Error: Expected an expression, {} is not a valid expression.", error_text)); + } + }; + return Some(format!("Syntax Error: Expected '=' or another variable name separated with by a comma and contained within parenthesis, got {}", error_text)); + } + "expression" => { + return Some(format!("Syntax Error: Unexpected, {} after an expression. Expected a binary operator or end of SET command", error_text)); + } + + _ => return None, + } + } + return None; + } + _ => { + return None; + } + } + } + } + _ => { + return None; + } + } + } + } + _ => { + return None; + } + } + } + None +} diff --git a/objectscript-lsp/src/lsp.rs b/objectscript-lsp/src/lsp.rs new file mode 100644 index 0000000..7d696bd --- /dev/null +++ b/objectscript-lsp/src/lsp.rs @@ -0,0 +1,1890 @@ +use crate::common::diagnostic_message; +use crate::server::BackendWrapper; +use objectscript_core::common::{ + advance_point, generic_exit_statements, generic_skipping_statements, get_node_children, + get_string_at_byte_range, parse_line_ref, point_to_byte, point_to_lsp_position, + position_to_point, ts_range_to_lsp_range, +}; +use objectscript_core::common::{ + collect_error_nodes, get_outer_type_from_identifier, xml_objectscript_implementation_ranges, +}; +use objectscript_core::config::Config; +use objectscript_core::parse_structures::{ClassId, FileType, MemberType, RefactorLevel}; +use objectscript_core::workspace::ProjectState; +use serde_json; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use tower_lsp::LanguageServer; +use tower_lsp::jsonrpc::Result; +use tower_lsp::lsp_types::request::{GotoImplementationParams, GotoImplementationResponse}; +use tower_lsp::lsp_types::{ + CodeAction, CodeActionKind, CodeActionOptions, CodeActionParams, CodeActionProviderCapability, + CodeActionResponse, CodeActionTriggerKind, Command, Diagnostic, DiagnosticOptions, + DiagnosticServerCapabilities, DiagnosticSeverity, DidChangeTextDocumentParams, + DidChangeWatchedFilesParams, DidChangeWatchedFilesRegistrationOptions, + DidOpenTextDocumentParams, DocumentDiagnosticParams, DocumentDiagnosticReport, + DocumentDiagnosticReportResult, ExecuteCommandOptions, ExecuteCommandParams, FileChangeType, + FileSystemWatcher, FullDocumentDiagnosticReport, GlobPattern, GotoDefinitionParams, + GotoDefinitionResponse, ImplementationProviderCapability, InitializeParams, InitializeResult, + InitializedParams, Location, MessageType, OneOf, Position, Range as LspRange, Range, + Registration, RelatedFullDocumentDiagnosticReport, ServerCapabilities, ServerInfo, + TextDocumentSyncCapability, TextDocumentSyncKind, TextEdit, WatchKind, WorkspaceEdit, +}; +use tree_sitter::{InputEdit, Parser, Point, Tree}; +use tree_sitter_objectscript_playground::LANGUAGE_OBJECTSCRIPT; + +const REFACTOR_DOCUMENT_COMMAND: &str = "objectscript.refactorDocument"; +const REFACTOR_WORKSPACE_COMMAND: &str = "objectscript.refactorWorkspace"; +const LEGACY_REFACTOR_WORKSPACE_DO_COMMAND: &str = "objectscript.refactorWorkspaceDottedDo"; +const USER_SELECTABLE_REFACTOR_LEVELS: [RefactorLevel; 4] = [ + RefactorLevel::DoCommands, + RefactorLevel::Conditionals, + RefactorLevel::ForCommands, + RefactorLevel::All, +]; + +fn empty_diagnostic_report() -> DocumentDiagnosticReportResult { + DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport { + related_documents: None, + full_document_diagnostic_report: FullDocumentDiagnosticReport { + result_id: None, + items: vec![], + }, + }) + .into() +} + +fn file_type_from_path(path: &str) -> Option { + if path.ends_with(".cls") { + Some(FileType::Cls) + } else if path.ends_with(".inc") + || path.ends_with(".rtn") + || path.ends_with(".mac") + || path.ends_with(".int") + { + Some(FileType::Routine) + } else if path.ends_with(".xml") { + Some(FileType::Xml) + } else { + None + } +} + +fn refactor_kind_requested(only: Option<&Vec>) -> bool { + only.map(|requested| { + requested.iter().any(|kind| { + CodeActionKind::REFACTOR_REWRITE + .as_str() + .starts_with(kind.as_str()) + }) + }) + .unwrap_or(true) +} + +fn full_document_range(text: &str) -> Range { + let line = text + .as_bytes() + .iter() + .filter(|&&byte| byte == b'\n') + .count() as u32; + let last_line_start = text.rfind('\n').map(|idx| idx + 1).unwrap_or(0); + let character = text[last_line_start..].encode_utf16().count() as u32; + + Range { + start: Position::new(0, 0), + end: Position::new(line, character), + } +} + +fn refactor_title(refactor_level: RefactorLevel, scope: &str) -> String { + let target = match refactor_level { + RefactorLevel::All => "All Code", + RefactorLevel::DoCommands => "Legacy Do Commands", + RefactorLevel::Conditionals => "Legacy If/Else Commands", + RefactorLevel::ForCommands => "Legacy For Commands", + }; + format!("Refactor {target} {scope}") +} + +fn selectable_document_refactor_levels(file_type: FileType) -> &'static [RefactorLevel] { + const ROUTINE_LEVELS: [RefactorLevel; 4] = [ + RefactorLevel::DoCommands, + RefactorLevel::Conditionals, + RefactorLevel::ForCommands, + RefactorLevel::All, + ]; + const CLASS_LEVELS: [RefactorLevel; 3] = [ + RefactorLevel::Conditionals, + RefactorLevel::ForCommands, + RefactorLevel::All, + ]; + const XML_LEVELS: [RefactorLevel; 0] = []; + + match file_type { + FileType::Routine => &ROUTINE_LEVELS, + FileType::Cls => &CLASS_LEVELS, + FileType::Xml => &XML_LEVELS, + } +} + +fn push_host_syntax_diagnostics( + diagnostics: &mut Vec, + content: &str, + tree: &Tree, + file_type: FileType, +) { + let error_nodes = collect_error_nodes(tree.root_node()); + + for node in error_nodes { + let lsp_range = ts_range_to_lsp_range(content, node.range()); + let error_text = + get_string_at_byte_range(content, node.byte_range()).unwrap_or_else(String::new); + let message = if file_type == FileType::Xml { + format!("XML syntax error: Unexpected {}", error_text) + } else { + diagnostic_message(node, error_text.as_str()) + .unwrap_or(format!("Syntax Error: Unexpected {}", error_text)) + }; + + diagnostics.push(Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::ERROR), + code: None, + code_description: None, + source: None, + message, + related_information: None, + tags: None, + data: None, + }); + } +} + +fn push_xml_injected_objectscript_diagnostics( + diagnostics: &mut Vec, + content: &str, + xml_tree: &Tree, +) { + let ranges = xml_objectscript_implementation_ranges(xml_tree.root_node(), content); + + for range in ranges { + let Some(text) = get_string_at_byte_range(content, range.start_byte..range.end_byte) else { + continue; + }; + if text.trim().is_empty() { + continue; + } + + let mut parser = Parser::new(); + if parser.set_language(&LANGUAGE_OBJECTSCRIPT.into()).is_err() { + continue; + } + if parser.set_included_ranges(&[range]).is_err() { + continue; + } + + let Some(tree) = parser.parse(content, None) else { + continue; + }; + + push_host_syntax_diagnostics(diagnostics, content, &tree, FileType::Routine); + } +} + +fn build_refactor_command( + command: &str, + uri: &tower_lsp::lsp_types::Url, + refactor_level: RefactorLevel, + scope: &str, +) -> Command { + Command { + title: refactor_title(refactor_level, scope), + command: command.to_string(), + arguments: Some(vec![ + Value::String(uri.to_string()), + command_refactor_level_value(refactor_level), + ]), + } +} + +fn build_document_refactor_edit( + project: &ProjectState, + uri: &tower_lsp::lsp_types::Url, + refactor_level: RefactorLevel, +) -> Option { + let (_, content, _, _) = project.get_document_info(uri)?; + let updated_content = project.refactor_document(uri, refactor_level)?; + let mut changes = HashMap::new(); + changes.insert( + uri.clone(), + vec![TextEdit::new( + full_document_range(content.as_str()), + updated_content, + )], + ); + Some(WorkspaceEdit::new(changes)) +} + +fn collect_workspace_refactor_changes( + project: &ProjectState, + refactor_level: RefactorLevel, +) -> HashMap> { + let mut changes = HashMap::new(); + + for (updated_content, url) in project.refactor(refactor_level) { + let Some((_, content, _, _)) = project.get_document_info(&url) else { + continue; + }; + + changes.insert( + url.clone(), + vec![TextEdit::new( + full_document_range(content.as_str()), + updated_content, + )], + ); + } + + changes +} + +fn command_uri_argument(arguments: &[Value]) -> Option { + let first = arguments.first()?; + let uri = serde_json::from_value::(first.clone()).ok()?; + tower_lsp::lsp_types::Url::parse(uri.as_str()).ok() +} + +fn command_refactor_level_argument(command: &str, arguments: &[Value]) -> Option { + if command == LEGACY_REFACTOR_WORKSPACE_DO_COMMAND { + return Some(RefactorLevel::DoCommands); + } + + let level = arguments.get(1)?.as_str()?; + match level { + "all" => Some(RefactorLevel::All), + "do" => Some(RefactorLevel::DoCommands), + "conditionals" | "if" | "if/else" => Some(RefactorLevel::Conditionals), + "for" => Some(RefactorLevel::ForCommands), + _ => None, + } +} + +fn command_refactor_level_value(refactor_level: RefactorLevel) -> Value { + let value = match refactor_level { + RefactorLevel::All => "all", + RefactorLevel::DoCommands => "do", + RefactorLevel::Conditionals => "conditionals", + RefactorLevel::ForCommands => "for", + }; + Value::String(value.to_string()) +} + +fn build_caps(cfg: &Config) -> ServerCapabilities { + ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::INCREMENTAL, + )), + definition_provider: Some(OneOf::Left(true)), + implementation_provider: Some(ImplementationProviderCapability::Simple(true)), + document_formatting_provider: cfg.enable_formatting.then_some(OneOf::Left(true)), + code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions { + code_action_kinds: Some(vec![CodeActionKind::REFACTOR_REWRITE]), + resolve_provider: Some(false), + work_done_progress_options: Default::default(), + })), + diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions { + identifier: None, + inter_file_dependencies: false, + workspace_diagnostics: false, + work_done_progress_options: Default::default(), + })), + execute_command_provider: Some(ExecuteCommandOptions { + commands: vec![ + REFACTOR_DOCUMENT_COMMAND.to_string(), + REFACTOR_WORKSPACE_COMMAND.to_string(), + LEGACY_REFACTOR_WORKSPACE_DO_COMMAND.to_string(), + ], + work_done_progress_options: Default::default(), + }), + experimental: Some(serde_json::json!({ + "objectscriptDependenciesProvider": true + })), + + // TODO: need to do dotted statement formatting + // document_formatting_provider: cfg.enable_formatting.then_some(OneOf::Left(true)), + ..Default::default() + } +} + +#[tower_lsp::async_trait] +impl LanguageServer for BackendWrapper { + async fn initialize(&self, params: InitializeParams) -> Result { + // negotiate w/ client to set config for formatting, lint, snippets + let negotiations: Config = params + .initialization_options + .and_then(|v| serde_json::from_value::(v).ok()) + .unwrap_or_default(); + + if let Some(folders) = params.workspace_folders { + for folder in folders { + let Ok(project_root) = folder.uri.to_file_path() else { + self.0 + .client + .log_message(MessageType::ERROR, "Failed to get project root path") + .await; + continue; + }; + // create projectState and set the projectRoot + let state = ProjectState::new(); + if state.project_root_path.set(Some(project_root)).is_err() { + self.0 + .client + .log_message( + MessageType::WARNING, + "project_root_path was already set; ignoring duplicate initialize", + ) + .await; + } + + // add projectState to projects + self.0.add_project(folder.uri, state); + } + } + Ok(InitializeResult { + capabilities: build_caps(&negotiations), + server_info: Some(ServerInfo { + name: "objectscript-lsp".to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + }) + } + + async fn initialized(&self, _: InitializedParams) { + // register watchers for any .cls and .inc files in the workspace + let globs = [ + "**/*.cls", "**/*.inc", "**/*.rtn", "**/*.mac", "**/*.int", "**/*.xml", + ]; + let watchers = globs + .into_iter() + .map(|g| FileSystemWatcher { + glob_pattern: GlobPattern::String(g.to_string()).into(), + kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), + }) + .collect(); + let options = DidChangeWatchedFilesRegistrationOptions { watchers }; + + let register_options = match serde_json::to_value(options) { + Ok(v) => Some(v), + Err(e) => { + self.0 + .client + .log_message(MessageType::ERROR, &e.to_string()) + .await; + None + } + }; + + let registration = Registration { + id: "ObjectScriptCacheWatcher".to_string(), + method: "workspace/didChangeWatchedFiles".to_string(), + register_options, + }; + + self.0 + .client + .register_capability(vec![registration]) + .await + .ok(); + + if let Ok(Some(folders)) = self.0.client.workspace_folders().await { + for workspace in folders { + let backend = Arc::clone(&self.0); + tokio::spawn(async move { + let _ = backend.index_workspace(&workspace.uri).await; + }); + } + } + } + + async fn code_action(&self, params: CodeActionParams) -> Result> { + if !refactor_kind_requested(params.context.only.as_ref()) { + return Ok(None); + } + + let uri = params.text_document.uri; + let Some(project) = self.0.get_project_from_document_url(&uri) else { + return Ok(None); + }; + let Some((file_type, _, _, tree)) = project.get_document_info(&uri) else { + return Ok(None); + }; + let document_is_parseable = !tree.root_node().has_error(); + + let mut actions = Vec::new(); + if document_is_parseable { + for refactor_level in selectable_document_refactor_levels(file_type.clone()) { + actions.push( + CodeAction { + title: refactor_title(*refactor_level, "in this document"), + kind: Some(CodeActionKind::REFACTOR_REWRITE), + diagnostics: None, + edit: None, + command: Some(build_refactor_command( + REFACTOR_DOCUMENT_COMMAND, + &uri, + *refactor_level, + "in this document", + )), + is_preferred: (*refactor_level == RefactorLevel::All).then_some(true), + disabled: None, + data: None, + } + .into(), + ); + } + } + + if file_type != FileType::Xml + && params.context.trigger_kind != Some(CodeActionTriggerKind::AUTOMATIC) + { + for refactor_level in USER_SELECTABLE_REFACTOR_LEVELS { + actions.push( + CodeAction { + title: refactor_title(refactor_level, "in workspace"), + kind: Some(CodeActionKind::REFACTOR_REWRITE), + diagnostics: None, + edit: None, + command: Some(build_refactor_command( + REFACTOR_WORKSPACE_COMMAND, + &uri, + refactor_level, + "in workspace", + )), + is_preferred: None, + disabled: None, + data: None, + } + .into(), + ); + } + } + + if actions.is_empty() { + Ok(None) + } else { + Ok(Some(actions)) + } + } + + async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) { + for change in params.changes { + let Some(file_type) = file_type_from_path(change.uri.path()) else { + continue; + }; + if change.typ == FileChangeType::DELETED { + continue; + } + + let Some(project) = self.0.get_project_from_document_url(&change.uri) else { + continue; + }; + let Ok(path) = change.uri.to_file_path() else { + continue; + }; + let Ok(text) = std::fs::read_to_string(path) else { + continue; + }; + let version = project + .get_document_info(&change.uri) + .map(|(_, _, version, _)| version) + .unwrap_or(0); + + project.handle_document_opened(change.uri, text, file_type, version); + } + } + + async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { + if params.command != REFACTOR_DOCUMENT_COMMAND + && params.command != REFACTOR_WORKSPACE_COMMAND + && params.command != LEGACY_REFACTOR_WORKSPACE_DO_COMMAND + { + return Ok(None); + } + + let Some(uri) = command_uri_argument(params.arguments.as_slice()) else { + self.0 + .client + .log_message( + MessageType::ERROR, + "Workspace refactor command is missing a document URI argument.", + ) + .await; + return Ok(None); + }; + let Some(refactor_level) = + command_refactor_level_argument(params.command.as_str(), params.arguments.as_slice()) + else { + self.0 + .client + .log_message( + MessageType::ERROR, + "Workspace refactor command is missing a valid refactor level argument.", + ) + .await; + return Ok(None); + }; + let Some(project) = self.0.get_project_from_document_url(&uri) else { + return Ok(None); + }; + + if params.command == REFACTOR_DOCUMENT_COMMAND { + let Some(edit) = build_document_refactor_edit(project.as_ref(), &uri, refactor_level) + else { + return Ok(None); + }; + + let response = self.0.client.apply_edit(edit).await?; + if !response.applied { + let reason = response + .failure_reason + .unwrap_or_else(|| "Unknown failure".to_string()); + self.0 + .client + .log_message( + MessageType::WARNING, + format!("Document refactor was not applied: {reason}",), + ) + .await; + } + } else { + let changes = collect_workspace_refactor_changes(project.as_ref(), refactor_level); + if changes.is_empty() { + return Ok(None); + } + + let response = self + .0 + .client + .apply_edit(WorkspaceEdit::new(changes)) + .await?; + if !response.applied { + let reason = response + .failure_reason + .unwrap_or_else(|| "Unknown failure".to_string()); + self.0 + .client + .log_message( + MessageType::WARNING, + format!("Workspace refactor was not applied: {reason}",), + ) + .await; + } + } + + Ok(None) + } + + async fn diagnostic( + &self, + params: DocumentDiagnosticParams, + ) -> Result { + let uri = params.text_document.uri; + let mut diagnostics: Vec = Vec::new(); + let Some(project) = self.0.get_project_from_document_url(&uri) else { + self.0 + .client + .log_message(MessageType::ERROR, "Failed to get project from document") + .await; + generic_exit_statements("LSP", "diagnostic"); + return Ok(empty_diagnostic_report()); + }; + let doc_snapshot: Option<(FileType, String, Tree)> = { + let data = project.data.read(); + data.documents + .get(&uri) + .map(|d| (d.file_type.clone(), d.content.clone(), d.tree.clone())) + }; + + let (file_type, content, tree) = match doc_snapshot { + Some(v) => v, + None => { + self.0 + .client + .log_message(MessageType::ERROR, "Failed to get document") + .await; + return Ok(empty_diagnostic_report()); + } + }; + let content = content.as_str(); + push_host_syntax_diagnostics(&mut diagnostics, content, &tree, file_type.clone()); + if file_type == FileType::Xml { + let host_count = diagnostics.len(); + push_xml_injected_objectscript_diagnostics(&mut diagnostics, content, &tree); + self.0 + .client + .log_message( + MessageType::INFO, + format!( + "XML diagnostic for {} -> host errors: {}, total errors after injected ObjectScript pass: {}", + uri, + host_count, + diagnostics.len() + ), + ) + .await; + } + Ok( + DocumentDiagnosticReport::Full(RelatedFullDocumentDiagnosticReport { + related_documents: None, + full_document_diagnostic_report: FullDocumentDiagnosticReport { + result_id: None, + items: diagnostics, + }, + }) + .into(), + ) + } + + async fn goto_definition( + &self, + params: GotoDefinitionParams, + ) -> Result> { + let uri = params.text_document_position_params.text_document.uri; + let position = params.text_document_position_params.position; + let mut locations: Vec = Vec::new(); + let mut definitions = Vec::new(); + let Some(project) = self.0.get_project_from_document_url(&uri) else { + self.0 + .client + .log_message( + MessageType::ERROR, + "Error: Failed to get project from document, aborting goto_definition", + ) + .await; + return Ok(None); + }; + let (content, tree, class_id, class_name): (String, Tree, ClassId, String) = { + let data = project.data.read(); + let Some(document) = data.documents.get(&uri) else { + eprintln!("Error: failed to get document for url {:?},", uri.path()); + return Ok(None); + }; + let Some(class_id) = document.class_id.clone() else { + return Ok(None); + }; + + let Some(class_name) = document.class_name.clone() else { + return Ok(None); + }; + ( + document.content.clone(), + document.tree.clone(), + class_id, + class_name, + ) + }; + let content = content.as_str(); + // find what node is at that position + // convert position to point, and find smallest node that has the range of that Point + let point = position_to_point(content, position); + let Some(node) = tree + .root_node() + .named_descendant_for_point_range(point, point) + else { + eprintln!("Error: Failed to get node at point, exiting (LSP, goto_definition)",); + return Ok(None); + }; + if node.kind() == "identifier" + || node.kind() == "objectscript_identifier" + || node.kind() == "objectscript_identifier_special" + { + let Some(name) = node.parent() else { + eprintln!("Warning: Identifier node does not have a parent"); + return Ok(None); + }; + let Some(node_type) = get_outer_type_from_identifier(&name) else { + return Ok(None); + }; + let Some(name_string) = get_string_at_byte_range(content, node.byte_range()) else { + self.0 + .client + .log_message( + MessageType::ERROR, + "Error: failed to identifier string in goto_implementation.", + ) + .await; + return Ok(None); + }; + match node_type { + MemberType::ClassDef => { + definitions = { + let data = project.data.read(); + data.get_class_superclasses(&class_id) + } + } + MemberType::MethodDef => { + definitions = { + let data = project.data.read(); + data.get_method_superclass(name_string, &class_id) + } + } + MemberType::Class => { + definitions = { + let data = project.data.read(); + data.get_class_definition(&name_string) + } + } + MemberType::RelativeMethodCall => { + definitions = { + let data = project.data.read(); + let method_ref = if let Some(m_ref) = data + .method_defs + .get(&class_name) + .and_then(|methods| methods.get(&name_string)) + { + m_ref + } else { + return Ok(None); + }; + data.get_method_definition(method_ref, None) + } + } + MemberType::LocalVariable | MemberType::GlobalVariable => { + definitions = { + let data = project.data.read(); + data.get_variable_definition(&uri, point, name_string) + } + } + MemberType::ClassMethodCall => { + let Some(method_name_parent) = name.parent() else { + eprintln!("Error: expected method_name node to have parent"); + return Ok(None); + }; + if let Some(class_ref) = method_name_parent.named_child(0) + && let Some(class_name_node) = class_ref.named_child(1) + { + // this part will remove the strings and such (it grabs the actual $.identifier node) + if let Some(class_name) = class_name_node.named_child(0) { + if let Some(class_name) = + get_string_at_byte_range(content, class_name.byte_range()) + { + definitions = { + let data = project.data.read(); + let method_ref = if let Some(m_ref) = data + .method_defs + .get(&class_name) + .and_then(|methods| methods.get(&name_string)) + { + m_ref + } else { + return Ok(None); + }; + data.get_method_definition(method_ref, None) + }; + } + } + } + } + MemberType::RoutineMethodCall => match name.kind() { + "method_name" => { + definitions = { + let data = project.data.read(); + let method_ref = if let Some(m_ref) = data + .method_defs + .get(&class_name) + .and_then(|methods| methods.get(&name_string)) + { + m_ref + } else { + return Ok(None); + }; + data.get_method_definition(method_ref, None) + } + } + "line_ref" => { + definitions = { + let data = project.data.read(); + let (method_name, routine_name, offset) = + parse_line_ref(name, content, class_name.clone()); + let method_ref = if let Some(m_ref) = data + .method_defs + .get(&routine_name) + .and_then(|methods| methods.get(&method_name)) + { + m_ref + } else { + return Ok(None); + }; + data.get_method_definition(method_ref, offset) + } + } + _ => return Ok(None), + }, + MemberType::OrefMethod => match name.kind() { + "method_name" => { + if let Some(oref_method) = name.parent() + && let Some(oref_method_parent) = oref_method.parent() + { + let oref_chain_expr = if oref_method_parent.kind() + == "oref_chain_segment" + && let Some(par) = oref_method_parent.parent() + { + par + } else if oref_method_parent.kind() == "do_parameter" { + oref_method_parent + } else { + eprintln!( + "Error: Expected oref_chain_segment or do_parameter node, got {:?}, aborting goto_definition", + oref_method_parent + ); + return Ok(None); + }; + + if oref_chain_expr.named_child_count() > 2 { + eprintln!( + "Error: Right now, analysis only supports 2 children for oref expression, aborting goto_definition" + ); + return Ok(None); + } + // name_string = method_name + // variable_name = + if let Some(var_name_node) = oref_chain_expr.named_child(0) { + if var_name_node.kind() == "lvn" + && let Some(var_name_str) = get_string_at_byte_range( + content, + var_name_node.byte_range(), + ) + { + definitions = { + let data = project.data.read(); + data.get_oref_method_definition( + &var_name_str, + &name_string, + &class_name, + point, + ) + } + } else if var_name_node.kind() == "class_method_call" { + if let Some(class_ref) = var_name_node.named_child(0) + && let Some(method_name_node) = var_name_node.named_child(1) + && let Some(class_name_node) = class_ref.named_child(1) + { + // this part will remove the strings and such (it grabs the actual $.identifier node) + if let Some(method_name) = method_name_node.named_child(0) + && let Some(curr_class_name) = + class_name_node.named_child(0) + { + if let Some(method_name) = get_string_at_byte_range( + content, + method_name.byte_range(), + ) { + if method_name.eq_ignore_ascii_case("%new") + && let Some(curr_class) = + get_string_at_byte_range( + content, + curr_class_name.byte_range(), + ) + { + definitions = { + let data = project.data.read(); + let method_ref = if let Some(m_ref) = data + .method_defs + .get(&curr_class) + .and_then(|methods| { + methods.get(&name_string) + }) { + m_ref + } else { + return Ok(None); + }; + data.get_method_definition(method_ref, None) + } + } + } + } + } + } + } + } else { + return Ok(None); + } + } + "lvn" => { + if let Some(lvn_parent) = name.parent() { + match lvn_parent.kind() { + "class_ref" => { + if let Some(class_ref_parent) = lvn_parent.parent() { + match class_ref_parent.kind() { + "class_method_call" => { + if let Some(method_name_outer) = + class_ref_parent.named_child(1) + && let Some(method_name) = + method_name_outer.named_child(0) + && let Some(method_name_str) = + get_string_at_byte_range( + content, + method_name.byte_range(), + ) + { + definitions = { + let data = project.data.read(); + data.get_oref_definition( + &uri, + point, + name_string, + method_name_str, + ) + } + } + } + "oref_chain_expr" => { + if class_ref_parent.named_child_count() > 2 { + eprintln!( + "Error: Right now, analysis only supports 2 children for oref expression, aborting goto_definition" + ); + return Ok(None); + } + if let Some(oref_chain_segment) = + class_ref_parent.named_child(1) + && let Some(oref_method) = + oref_chain_segment.named_child(0) + && let Some(method_name_node) = + oref_method.named_child(0) + && let Some(method_name_identifier) = + method_name_node.named_child(0) + && let Some(method_name_str) = + get_string_at_byte_range( + content, + method_name_identifier.byte_range(), + ) + { + definitions = { + let data = project.data.read(); + data.get_oref_definition( + &uri, + point, + name_string, + method_name_str, + ) + } + } + } + _ => { + eprintln!( + "Error: Unsupported node kind for class ref parent {:?}, aborting (goto_definition)", + class_ref_parent.kind() + ); + } + } + } + } + "do_parameter" => { + if lvn_parent.named_child_count() > 2 { + eprintln!( + "Error: Right now, analysis only supports 2 children for oref expression, aborting goto_definition" + ); + return Ok(None); + } + if let Some(method_name_node) = lvn_parent.named_child(1) + && let Some(method_name_str) = get_string_at_byte_range( + content, + method_name_node.byte_range(), + ) + { + definitions = { + let data = project.data.read(); + data.get_oref_definition( + &uri, + point, + name_string, + method_name_str, + ) + } + } + } + "oref_chain_expr" => { + if lvn_parent.named_child_count() > 2 { + eprintln!( + "Error: Right now, analysis only supports 2 children for oref expression, aborting goto_definition" + ); + return Ok(None); + } + if let Some(oref_chain_segment) = lvn_parent.named_child(1) + && let Some(oref_method) = oref_chain_segment.named_child(0) + && let Some(method_name_node) = oref_method.named_child(0) + && let Some(method_name_identifier) = + method_name_node.named_child(0) + && let Some(method_name_str) = get_string_at_byte_range( + content, + method_name_identifier.byte_range(), + ) + { + definitions = { + let data = project.data.read(); + data.get_oref_definition( + &uri, + point, + name_string, + method_name_str, + ) + } + } + } + _ => {} + } + } else { + eprintln!( + "Error: Expected Lvn Node, got {:?}, and it did not have a parent node", + name.kind() + ); + return Ok(None); + } + } + _ => { + eprintln!( + "Error: In MemberType::OrefMethod branch of goto_definition, unsupported node type: {:?}, returning None", + name.kind() + ); + } + }, + _ => { + definitions = { + let data = project.data.read(); + data.get_variable_definition(&uri, point, name_string) + } + } + } + } else if node.kind() == "routine_name" { + let Some(routine_name) = get_string_at_byte_range(content, node.byte_range()) else { + return Ok(None); + }; + let Some(routine_ref) = node.parent() else { + eprintln!("Error: routine_name node does not have a parent"); + return Ok(None); + }; + if routine_ref.kind() != "routine_ref" { + eprintln!("Error: expected routine_name node parent to be routine_ref"); + return Ok(None); + } + definitions = { + let data = project.data.read(); + data.get_class_definition(&routine_name) + } + } else if node.kind() == "gvn" { + let gvn_children = get_node_children(node); + for gvn_child in gvn_children { + if gvn_child.kind() == "identifier" { + if let Some(gvn_id) = get_string_at_byte_range(content, gvn_child.byte_range()) + { + definitions = { + let data = project.data.read(); + let point = position_to_point(content, position); + data.get_variable_definition(&uri, point, gvn_id) + } + } + } + } + } else if node.kind() == "lvn" { + // this is the case where a base regex is aliased as a lvn (so it shouldn't have any children) + let Some(lvn_id_node) = node.named_child(0) else { + return Ok(None); + }; + if let Some(lvn_id) = get_string_at_byte_range(content, lvn_id_node.byte_range()) { + definitions = { + let data = project.data.read(); + let point = position_to_point(content, position); + data.get_variable_definition(&uri, point, lvn_id) + } + } + } else if node.kind() == "numeric_literal" { + if let Some(parent) = node.parent() { + if parent.kind() == "zbreak_location" + || parent.kind() == "routine_tag_call" + || parent.kind() == "print_argument" + || parent.kind() == "goto_argument" + { + let Some(num_str) = get_string_at_byte_range(content, node.byte_range()) else { + return Ok(None); + }; + match num_str.trim().parse::() { + Ok(n) => { + let new_point = Point { + row: point.row + n, + column: point.column, + }; + let start = point_to_lsp_position(content, new_point); + let lsp_range = LspRange { start, end: start }; + let location = Location { + uri: uri.clone(), + range: lsp_range, + }; + return Ok(Some(GotoDefinitionResponse::Scalar(location))); + } + Err(_) => return Ok(None), + } + } + } + } + for (url, range) in definitions { + let data = project.data.read(); + let Some(document) = data.documents.get(&url) else { + eprintln!("Error: Couldn't get document content, skipping in goto_definition"); + continue; + }; + let document_content = document.content.as_str(); + let lsp_range = ts_range_to_lsp_range(document_content, range); + let location = Location { + uri: url.clone(), + range: lsp_range, + }; + locations.push(location); + } + + return if locations.is_empty() { + eprintln!( + "Error: Symbol {:?} is not defined in this workspace (goto_definition).", + node.kind() + ); + Ok(None) + } else if locations.len() == 1 { + Ok(Some(GotoDefinitionResponse::Scalar(locations[0].clone()))) + } else { + Ok(Some(GotoDefinitionResponse::Array(locations))) + }; + } + + async fn goto_implementation( + &self, + params: GotoImplementationParams, + ) -> Result> { + let uri = params.text_document_position_params.text_document.uri; + let position = params.text_document_position_params.position; + let mut locations = Vec::new(); + let mut overrides = Vec::new(); + let Some(project) = self.0.get_project_from_document_url(&uri) else { + self.0 + .client + .log_message( + MessageType::ERROR, + "Error: Failed to get project from document aborting goto_implementation", + ) + .await; + + return Ok(None); + }; + let (content, tree, class_id): (String, Tree, ClassId) = { + let data = project.data.read(); + let Some(document) = data.documents.get(&uri) else { + eprintln!("Error: failed to get document for url {:?},", uri.path()); + return Ok(None); + }; + let Some(class_id) = document.class_id else { + eprintln!("Error: Class ID doesn't exist for url {:?}", uri.path()); + return Ok(None); + }; + (document.content.clone(), document.tree.clone(), class_id) + }; + let content = content.as_str(); + // find what node is at that position + // convert position to point, and find smallest node that has the range of that Point + let point = position_to_point(content, position); + let Some(node) = tree + .root_node() + .named_descendant_for_point_range(point, point) + else { + self.0 + .client + .log_message( + MessageType::ERROR, + format!( + "Error: Failed to get node at point, exiting (LSP, goto_implementation)", + ), + ) + .await; + return Ok(None); + }; + if node.kind() == "identifier" || node.kind() == "objectscript_identifier" { + let Some(name) = node.parent() else { + eprintln!("Warning: Identifier node does not have a parent"); + return Ok(None); + }; + let Some(node_type) = get_outer_type_from_identifier(&name) else { + return Ok(None); + }; + let Some(name_string) = get_string_at_byte_range(content, node.byte_range()) else { + self.0 + .client + .log_message( + MessageType::ERROR, + "Error: failed to identifier string in goto_implementation.", + ) + .await; + return Ok(None); + }; + match node_type { + MemberType::Class => { + let Some(class_name_parent) = name.parent() else { + eprintln!( + "Error: failed to get parent node for class name node, exiting goto_implementation" + ); + return Ok(None); + }; + match class_name_parent.kind() { + "class_definition" => { + overrides = { + let data = project.data.read(); + data.get_class_implementations(&class_id) + }; + } + _ => { + // TODO: Need to get class ID for the actual class this is referencing + let Some(class_name_str) = + get_string_at_byte_range(content, name.byte_range()) + else { + eprintln!( + "Error: Couldn't get class name string, exiting goto_implementation" + ); + return Ok(None); + }; + overrides = { + let data = project.data.read(); + let Some(curr_class_id) = data.classes.get(&class_name_str) else { + eprintln!( + "Error: Class ID DNE for class {:?}, leaving goto_implementation", + class_name_str + ); + return Ok(None); + }; + data.get_class_implementations(curr_class_id) + }; + } + } + } + MemberType::ClassMethodCall => { + // In this case, the identifier (name_string) is the method name + let Some(class_method_call) = name.parent() else { + eprintln!("Error: expected method_name node to have parent"); + return Ok(None); + }; + if let Some(class_ref) = class_method_call.named_child(0) + && let Some(class_name_node) = class_ref.named_child(1) + { + // this part will remove the strings and such (it grabs the actual $.identifier node) + if let Some(class_name) = class_name_node.named_child(0) + && let Some(class_name_str) = + get_string_at_byte_range(content, class_name.byte_range()) + { + overrides = { + let data = project.data.read(); + if let Some(method_ref) = data + .method_defs + .get(&class_name_str) + .and_then(|methods| methods.get(&name_string)) + { + data.get_method_overrides(method_ref) + } else { + return Ok(None); + } + }; + } + } + } + MemberType::MethodDef => { + overrides = { + let data = project.data.read(); + if let Some(class) = data.global_semantic_model.get_class(&class_id) + && let Some(method_ref) = class.methods.get(&name_string) + { + data.get_method_overrides(&method_ref) + } else { + return Ok(None); + } + }; + } + _ => return Ok(None), + } + } + for (uri, range) in &overrides { + let data = project.data.read(); + let Some(document_content) = data.documents.get(uri).map(|d| d.content.as_str()) else { + eprintln!("Error: failed to get document of uri {}", uri.path()); + generic_skipping_statements( + "goto_implementation", + uri.path(), + "Url, failed to get document", + ); + continue; + }; + let lsp_range = ts_range_to_lsp_range(document_content, *range); + let location = Location { + uri: uri.clone(), + range: lsp_range, + }; + locations.push(location); + } + if locations.len() == 1 { + Ok(Some(GotoImplementationResponse::Scalar( + locations[0].clone(), + ))) + } else if locations.is_empty() { + self.0 + .client + .log_message( + MessageType::WARNING, + "No implementations were found for the given symbol.", + ) + .await; + Ok(None) + } else { + Ok(Some(GotoImplementationResponse::Array(locations))) + } + } + + async fn shutdown(&self) -> Result<()> { + // need to look more into if this is good for doing nothing + Ok(()) + } + + async fn did_open(&self, params: DidOpenTextDocumentParams) { + let uri = params.text_document.uri; + let path = uri.path(); + if !path.ends_with(".cls") + && !path.ends_with(".inc") + && !path.ends_with(".rtn") + && !path.ends_with(".mac") + && !path.ends_with(".int") + && !path.ends_with(".xml") + { + return; + } + let file_type = if path.ends_with(".cls") { + FileType::Cls + } else if path.ends_with(".xml") { + FileType::Xml + } else { + FileType::Routine + }; + if file_type == FileType::Xml { + self.0 + .client + .log_message( + MessageType::INFO, + format!("Tracking XML document in objectscript-lsp: {}", uri), + ) + .await; + } + self.0.handle_did_open( + uri, + params.text_document.text, + file_type, + params.text_document.version, + ); + } + + async fn did_change(&self, params: DidChangeTextDocumentParams) { + let uri = params.text_document.uri; + let path = uri.path(); + if !path.ends_with(".cls") + && !path.ends_with(".inc") + && !path.ends_with(".rtn") + && !path.ends_with(".mac") + && !path.ends_with(".int") + && !path.ends_with(".xml") + { + return; + } + let Some(project) = self.0.get_project_from_document_url(&uri) else { + return; + }; + let Some((file_type, mut old_text, old_version, mut old_tree)) = + project.get_document_info(&uri) + else { + let new_version = params.text_document.version; + let file_type = if path.ends_with(".cls") { + FileType::Cls + } else if path.ends_with(".xml") { + FileType::Xml + } else { + FileType::Routine + }; + // Try to get current cached doc + + // Base text: prefer disk if available, otherwise empty. + let mut text = if let Ok(p) = uri.to_file_path() { + std::fs::read_to_string(p).unwrap_or_default() + } else { + String::new() + }; + + for change in ¶ms.content_changes { + let Some(range) = change.range else { + text = change.text.clone(); + continue; + }; + + let start_point = position_to_point(&text, range.start); + let start_byte = point_to_byte(&text, start_point); + + let end_point = position_to_point(&text, range.end); + let end_byte = point_to_byte(&text, end_point); + + text.replace_range(start_byte..end_byte, &change.text); + } + + // Reuse normal open handling so XML docs are tracked and ObjectScript docs still + // populate their semantic state when a change arrives before an explicit didOpen. + project.handle_document_opened(uri, text, file_type, new_version); + return; + }; + + let new_version = params.text_document.version; + if new_version < old_version { + self.0 + .client + .log_message( + MessageType::ERROR, + "New version {new_version} is less than old version {old_version}", + ) + .await; + } + + let full_snapshot = params + .content_changes + .iter() + .rev() + .find(|c| c.range.is_none()) + .map(|c| c.text.clone()); + + let did_full_replace = full_snapshot.is_some(); + self.0 + .client + .log_message( + MessageType::INFO, + format!("Full Replace: {:?}", did_full_replace), + ) + .await; + if let Some(new_full_text) = full_snapshot { + // Full replace: overwrite text, DO NOT edit the old tree incrementally. + old_text = new_full_text; + } else { + // Incremental edits: apply each ranged edit sequentially. + for change in ¶ms.content_changes { + let range = change + .range + .expect("no full snapshot, so all changes must have ranges"); + let new_text = change.text.as_str(); + + let start_position = position_to_point(old_text.as_str(), range.start); + let start_byte = point_to_byte(old_text.as_str(), start_position); + + let old_end_position = position_to_point(old_text.as_str(), range.end); + let old_end_byte = point_to_byte(old_text.as_str(), old_end_position); + + let new_end_byte = start_byte + new_text.len(); + let new_end_position = + advance_point(start_position.row, start_position.column, new_text); + + let input_edit = InputEdit { + start_byte, + old_end_byte, + new_end_byte, + start_position, + old_end_position, + new_end_position, + }; + old_text.replace_range(start_byte..old_end_byte, new_text); + old_tree.edit(&input_edit); + } + } + + let parsed: Option = match file_type { + FileType::Cls => { + let mut parser = project.parsers.cls.lock(); + if did_full_replace { + parser.parse(&old_text, None) + } else { + parser.parse(&old_text, Some(&old_tree)) + } + } + FileType::Routine => { + let mut parser = project.parsers.routine.lock(); + if did_full_replace { + parser.parse(&old_text, None) + } else { + parser.parse(&old_text, Some(&old_tree)) + } + } + FileType::Xml => { + let mut parser = project.parsers.xml.lock(); + parser.parse(&old_text, None) + } + }; // lock guard drops here + + let new_tree = match parsed { + Some(t) => t, + None => { + self.0 + .client + .log_message( + MessageType::WARNING, + "Incremental parse failed.".to_string(), + ) + .await; + return; + } + }; + + { + let mut data = project.data.write(); + if let Some(doc) = data.documents.get_mut(&uri) { + doc.content = old_text.clone(); + doc.tree = new_tree.clone(); + doc.version = Some(new_version); + doc.file_type = file_type.clone(); + } + } + + if file_type == FileType::Xml { + return; + } + + if new_tree.root_node().has_error() { + self.0 + .client + .log_message(MessageType::ERROR, format!("New Tree has Errors")) + .await; + } else { + project.update_document(uri, new_tree, file_type, new_version, old_text.as_str()); + } + } + + // async fn did_close(&self, params: DidCloseTextDocumentParams) {} +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend_testing::BackendTester; + use objectscript_core::workspace::ProjectState; + use std::env; + use std::path::PathBuf; + use tower_lsp::LspService; + use tower_lsp::lsp_types::{ + DocumentDiagnosticReport, DocumentDiagnosticReportResult, PartialResultParams, + TextDocumentIdentifier, Url, WorkDoneProgressParams, + }; + + async fn setup_backend_and_workspace(project_root: PathBuf) -> (BackendTester, Url) { + let state = ProjectState::new(); + if state + .project_root_path + .set(Some(project_root.clone())) + .is_err() + { + eprintln!("failed to set the root path"); + } + let backend = BackendTester::new(); + let uri = Url::from_file_path(project_root).unwrap(); + backend.add_project(uri.clone(), state); + let _ = backend.index_workspace(&uri).await; + (backend, uri) + } + + #[test] + fn build_document_refactor_edit_returns_none_when_no_change() { + let state = ProjectState::new(); + let uri = Url::parse("file:///tmp/example.mac").unwrap(); + let content = "ROUTINE test\n\nmain\n quit\n"; + state.handle_document_opened(uri.clone(), content.to_string(), FileType::Routine, 1); + assert!(build_document_refactor_edit(&state, &uri, RefactorLevel::DoCommands).is_none()); + } + + #[test] + fn build_refactor_command_uses_conditionals_label_and_argument() { + let uri = Url::parse("file:///tmp/example.mac").unwrap(); + let command = build_refactor_command( + REFACTOR_DOCUMENT_COMMAND, + &uri, + RefactorLevel::Conditionals, + "in this document", + ); + + assert_eq!(command.command, REFACTOR_DOCUMENT_COMMAND); + assert_eq!( + command.title, + "Refactor Legacy If/Else Commands in this document" + ); + assert_eq!( + command.arguments, + Some(vec![ + Value::String(uri.to_string()), + Value::String("conditionals".to_string()), + ]) + ); + } + + #[test] + fn command_refactor_level_argument_supports_new_and_legacy_commands() { + let uri = Value::String("file:///tmp/example.mac".to_string()); + + assert_eq!( + command_refactor_level_argument( + REFACTOR_WORKSPACE_COMMAND, + &[uri.clone(), Value::String("all".to_string())] + ), + Some(RefactorLevel::All) + ); + assert_eq!( + command_refactor_level_argument( + REFACTOR_WORKSPACE_COMMAND, + &[uri.clone(), Value::String("conditionals".to_string())] + ), + Some(RefactorLevel::Conditionals) + ); + assert_eq!( + command_refactor_level_argument( + REFACTOR_WORKSPACE_COMMAND, + &[uri.clone(), Value::String("if/else".to_string())] + ), + Some(RefactorLevel::Conditionals) + ); + assert_eq!( + command_refactor_level_argument( + REFACTOR_WORKSPACE_COMMAND, + &[uri.clone(), Value::String("for".to_string())] + ), + Some(RefactorLevel::ForCommands) + ); + assert_eq!( + command_refactor_level_argument(LEGACY_REFACTOR_WORKSPACE_DO_COMMAND, &[uri]), + Some(RefactorLevel::DoCommands) + ); + } + + #[test] + fn build_refactor_command_keeps_do_refactor_selectable_for_class_documents() { + let uri = Url::parse("file:///tmp/example.cls").unwrap(); + let command = build_refactor_command( + REFACTOR_DOCUMENT_COMMAND, + &uri, + RefactorLevel::DoCommands, + "in this document", + ); + + assert_eq!(command.command, REFACTOR_DOCUMENT_COMMAND); + assert_eq!( + command.title, + "Refactor Legacy Do Commands in this document" + ); + assert_eq!( + command.arguments, + Some(vec![ + Value::String(uri.to_string()), + Value::String("do".to_string()), + ]) + ); + } + + #[test] + fn file_type_from_path_detects_xml_documents() { + assert_eq!(file_type_from_path("/tmp/test.xml"), Some(FileType::Xml)); + } + + #[test] + fn handle_document_opened_tracks_xml_without_semantic_analysis() { + let state = ProjectState::new(); + let uri = Url::parse("file:///tmp/example.xml").unwrap(); + let content = r#" + + + +"#; + + state.handle_document_opened(uri.clone(), content.to_string(), FileType::Xml, 1); + + let data = state.data.read(); + let document = data.documents.get(&uri).expect("missing xml document"); + + assert_eq!(document.file_type, FileType::Xml); + assert!(document.class_id.is_none()); + assert!(document.class_name.is_none()); + } + + #[test] + fn xml_implementation_blocks_produce_objectscript_diagnostics() { + let state = ProjectState::new(); + let uri = Url::parse("file:///tmp/example.xml").unwrap(); + let content = r#" + + + +"#; + + state.handle_document_opened(uri.clone(), content.to_string(), FileType::Xml, 1); + let (_, stored_content, _, tree) = state + .get_document_info(&uri) + .expect("missing stored xml document"); + + let mut diagnostics = Vec::new(); + push_xml_injected_objectscript_diagnostics( + &mut diagnostics, + stored_content.as_str(), + &tree, + ); + + assert!(!diagnostics.is_empty()); + } + + #[test] + fn xml_implementation_blocks_with_fake_cdata_markers_produce_diagnostics() { + let state = ProjectState::new(); + let uri = Url::parse("file:///tmp/export.xml").unwrap(); + let content = r#" + + + + ![CDATA [ + s x = 2 + w !, x "2" + if x = 2 { + w !, "hello" + q + } + else { + w "goodbye" + q + } +]] + + + +"#; + + state.handle_document_opened(uri.clone(), content.to_string(), FileType::Xml, 1); + let (_, stored_content, _, tree) = state + .get_document_info(&uri) + .expect("missing stored xml document"); + + let ranges = + super::xml_objectscript_implementation_ranges(tree.root_node(), &stored_content); + assert!(!ranges.is_empty(), "expected injection ranges"); + + let mut diagnostics = Vec::new(); + push_xml_injected_objectscript_diagnostics( + &mut diagnostics, + stored_content.as_str(), + &tree, + ); + + assert!( + !diagnostics.is_empty(), + "expected injected diagnostics, ranges were: {:?}", + ranges + ); + } + + #[tokio::test] + async fn diagnostic_request_returns_injected_objectscript_errors_for_xml_documents() { + let (service, _socket) = LspService::build(|client| BackendWrapper::new(client)).finish(); + let backend = service.inner(); + + let workspace_root = env::current_dir().unwrap(); + let workspace_uri = Url::from_file_path(&workspace_root).unwrap(); + let state = ProjectState::new(); + state + .project_root_path + .set(Some(workspace_root.clone())) + .expect("failed to set workspace root"); + backend.0.add_project(workspace_uri.clone(), state); + + let uri = Url::from_file_path(workspace_root.join("objectscript-tests").join("export.xml")) + .unwrap(); + let content = r#" + + + + ![CDATA [ + s x = 2 + w !, x "2" + if x = 2 { + w !, "hello" + q + } + else { + w "goodbye" + q + } +]] + + + +"#; + + let project = backend + .0 + .get_project(&workspace_uri) + .expect("missing project state"); + project.handle_document_opened(uri.clone(), content.to_string(), FileType::Xml, 1); + + let report = backend + .diagnostic(DocumentDiagnosticParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + identifier: None, + previous_result_id: None, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .expect("diagnostic request failed"); + + let items = match report { + DocumentDiagnosticReportResult::Report(DocumentDiagnosticReport::Full(report)) => { + report.full_document_diagnostic_report.items + } + other => panic!("unexpected diagnostic report: {other:?}"), + }; + + assert!( + !items.is_empty(), + "expected diagnostic() to return injected ObjectScript errors for XML" + ); + } + + #[tokio::test] + async fn collect_workspace_refactor_changes_finds_routine_edits() { + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("routines"); + let test_mac_url = Url::from_file_path(project_root.join("test-refactor-do.mac")).unwrap(); + + let (backend, uri) = setup_backend_and_workspace(project_root).await; + let project = backend.get_project(&uri).expect("missing project state"); + let changes = + collect_workspace_refactor_changes(project.as_ref(), RefactorLevel::DoCommands); + + assert!(changes.contains_key(&test_mac_url)); + } + + #[tokio::test] + async fn collect_workspace_refactor_changes_finds_conditional_edits() { + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("routines"); + let test_mac_url = Url::from_file_path(project_root.join("test-refactor-do.mac")).unwrap(); + + let (backend, uri) = setup_backend_and_workspace(project_root).await; + let project = backend.get_project(&uri).expect("missing project state"); + let changes = + collect_workspace_refactor_changes(project.as_ref(), RefactorLevel::Conditionals); + + assert!(changes.contains_key(&test_mac_url)); + } +} diff --git a/objectscript-lsp/src/main.rs b/objectscript-lsp/src/main.rs new file mode 100644 index 0000000..49c96bb --- /dev/null +++ b/objectscript-lsp/src/main.rs @@ -0,0 +1,17 @@ +use crate::server::BackendWrapper; +use tower_lsp::{LspService, Server}; +#[cfg(test)] +mod backend_testing; +mod common; +mod lsp; +mod server; +#[cfg(test)] +mod test; + +#[tokio::main] +async fn main() { + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + let (service, socket) = LspService::build(|client| BackendWrapper::new(client)).finish(); + Server::new(stdin, stdout, socket).serve(service).await; +} diff --git a/objectscript-lsp/src/server.rs b/objectscript-lsp/src/server.rs new file mode 100644 index 0000000..3ed9b59 --- /dev/null +++ b/objectscript-lsp/src/server.rs @@ -0,0 +1,224 @@ +use objectscript_core::common::get_member_name_from_root; +use objectscript_core::parse_structures::FileType; +use objectscript_core::workspace::ProjectState; +use parking_lot::RwLock; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tower_lsp::Client; +use tower_lsp::lsp_types::{MessageType, Url}; +use tree_sitter::Parser; +use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; +use tree_sitter_objectscript_routine::LANGUAGE_OBJECTSCRIPT_ROUTINE; +use walkdir::WalkDir; + +/// Arc-wrapped backend providing the LSP language server implementation. +pub struct BackendWrapper(pub(crate) Arc); +impl BackendWrapper { + /// Create a reference-counted backend wrapper around a new `Backend`. + pub fn new(client: Client) -> Self { + Self(Arc::new(Backend::new(client))) + } +} +pub(crate) struct Backend { + /// LSP Client. + pub(crate) client: Client, + /// Stores Url -> ProjectState for each Workspace. + pub(crate) projects: Arc>>>, +} + +impl Backend { + /// Construct a new backend with an empty projects map. + pub(crate) fn new(client: Client) -> Self { + Self { + client, + projects: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Register a workspace (project) and its initial `ProjectState` by workspace URI. + pub(crate) fn add_project(&self, uri: Url, state: ProjectState) { + self.projects.write().insert(uri, Arc::new(state)); + } + + /// Fetch a project by its workspace URI. + /// + /// Returns a cloned `Arc` to the project state, or `None` if the workspace is not registered. + pub fn get_project(&self, uri: &Url) -> Option> { + let result = self.projects.read().get(uri).cloned(); + result + } + + /// Find the workspace URI that most specifically contains the given document URI. + /// + /// Converts the document URI to a file path and selects the registered workspace whose path is + /// the longest prefix of that document path (i.e., the deepest matching workspace). + fn find_parent_workspace(&self, uri: Url) -> Option { + let doc_path: PathBuf = uri.to_file_path().ok()?; + + // find longest prefix + let projects = self.projects.read(); + + let parent = projects + .keys() + .filter_map(|ws_uri| { + let ws_path = ws_uri.to_file_path().ok()?; + if doc_path.starts_with(&ws_path) { + Some((ws_path.components().count(), ws_uri.clone())) + } else { + None + } + }) + .max_by_key(|(depth, _)| *depth) + .map(|(_, ws_uri)| ws_uri); + parent + } + + /// Resolve the `ProjectState` associated with a document URI. + /// + /// This first finds the containing workspace (if any), then returns that project's state. + pub(crate) fn get_project_from_document_url(&self, uri: &Url) -> Option> { + let project_url = self.find_parent_workspace(uri.clone())?; + let result = self.get_project(&project_url); + result + } + + /// Handle an LSP "didOpen" for a document by forwarding it to the owning project. + /// + /// If no workspace contains `uri`, this is a no-op. + pub fn handle_did_open(&self, uri: Url, text: String, file_type: FileType, version: i32) { + let Some(project) = self.get_project_from_document_url(&uri) else { + return; + }; + project.handle_document_opened(uri, text, file_type, version); + } + + /// Index all `.cls` and `.inc` files under the workspace root containing `uri`. + /// + /// This runs filesystem walking and parsing on Tokio's blocking thread pool. Each file is read, + /// parsed with the appropriate Tree-sitter grammar, and inserted into the project's document + /// store if absent. After the scan, inheritance and variable information is built once. + pub(crate) async fn index_workspace(&self, uri: &Url) { + let Some(project) = self.get_project_from_document_url(&uri) else { + eprintln!( + "Failed to get project from document with url: {:?}", + uri.path() + ); + return; + }; + let Some(root) = project.root_path() else { + self.client + .log_message(MessageType::ERROR, "project root path doesn't exist") + .await; + return; + }; + let root = root.to_path_buf(); + // Run indexing on Tokio's blocking thread pool + let handle = tokio::task::spawn_blocking(move || { + let mut cls_parser = Parser::new(); + if cls_parser + .set_language(&LANGUAGE_OBJECTSCRIPT_UDL.into()) + .is_err() + { + eprintln!("Error: Failed to load ObjectScript UDL grammar"); + return; + } + + let mut routine_parser = Parser::new(); + if routine_parser + .set_language(&LANGUAGE_OBJECTSCRIPT_ROUTINE.into()) + .is_err() + { + eprintln!("Error: Failed to load ObjectScript routine grammar"); + return; + } + + let mut documents_already_existing = Vec::new(); + for entry in WalkDir::new(&root).into_iter().filter_map(|e| e.ok()) { + let path = entry.path(); + + let Some(ext) = path.extension().and_then(|s| s.to_str()) else { + continue; + }; + + let (filetype, is_rtn) = match ext { + "cls" => (FileType::Cls, false), + "inc" => (FileType::Routine, true), + "rtn" => (FileType::Routine, true), + "mac" => (FileType::Routine, true), + "int" => (FileType::Routine, true), + "xml" => (FileType::Xml, false), + _ => continue, + }; + + let content = match std::fs::read_to_string(path) { + Ok(s) => s, + Err(_) => { + eprintln!("Error: Failed to read file contents: {}", path.display()); + continue; + } + }; + + let url = match Url::from_file_path(path) { + Ok(u) => u, + Err(_) => { + eprintln!("Error: Failed to convert path to Url: {}", path.display()); + continue; + } + }; + + let tree = if is_rtn { + match routine_parser.parse(&content, None) { + Some(t) => t, + None => { + eprintln!("Failed to parse file for: {:?}", path.display()); + continue; + } + } + } else { + match cls_parser.parse(&content, None) { + Some(t) => t, + None => { + eprintln!("Failed to parse file for: {:?}", path.display()); + continue; + } + } + }; + + let member_name = get_member_name_from_root(&content, tree.root_node(), is_rtn); + + if member_name.is_none() { + eprintln!( + "Error: Failed to get name from root node for file url: {:?}", + url.path() + ); + continue; + } + + // Commit inside the ProjectData lock + { + let mut data = project.data.write(); + let already_exists = data.add_document_if_absent( + url.clone(), + content, + tree, + filetype, + member_name, + None, + ); + if already_exists { + documents_already_existing.push(url); + } + } + } + { + let mut data = project.data.write(); + data.build_inheritance_and_variables(None, documents_already_existing); + } + }); + // Wait for completion (and handle join errors) + if let Err(join_err) = handle.await { + eprintln!("Error: index_workspace_scope spawn_blocking failed: {join_err:?}"); + } + } +} diff --git a/objectscript-lsp/src/test.rs b/objectscript-lsp/src/test.rs new file mode 100644 index 0000000..b471ab8 --- /dev/null +++ b/objectscript-lsp/src/test.rs @@ -0,0 +1,809 @@ +#[cfg(test)] +mod tests { + use crate::backend_testing::BackendTester; + use objectscript_core::common::get_keyword_and_value; + use objectscript_core::parse_structures::{FileType, Language, RefactorLevel, VariableRef}; + use objectscript_core::refactor::{refactor_conditionals, refactor_legacy_do_statements}; + use objectscript_core::workspace::ProjectState; + use std::collections::HashSet; + use std::env; + use std::path::PathBuf; + use tower_lsp::lsp_types::Url; + use tree_sitter::{Parser, Point}; + use tree_sitter_objectscript_routine::LANGUAGE_OBJECTSCRIPT_ROUTINE; + + fn parse_routine(code: &str) -> tree_sitter::Tree { + let mut parser = Parser::new(); + parser + .set_language(&LANGUAGE_OBJECTSCRIPT_ROUTINE.into()) + .expect("failed to load objectscript grammar"); + parser.parse(code, None).expect("parse returned None") + } + + async fn setup_backend_and_workspace(project_root: PathBuf) -> (BackendTester, Url) { + // create projectState and set the projectRoot + let state = ProjectState::new(); + if state + .project_root_path + .set(Some(project_root.clone())) + .is_err() + { + eprintln!("failed to set the root path"); + } + let backend = BackendTester::new(); + let uri = Url::from_file_path(project_root).unwrap(); + backend.add_project(uri.clone(), state); + + let _ = backend.index_workspace(&uri).await; + (backend, uri) + } + + fn point_for_substring_n(content: &str, needle: &str, occurrence: usize) -> Point { + assert!(occurrence > 0, "occurrence must be >= 1"); + let mut start = 0usize; + let mut found_at = None; + for _ in 0..occurrence { + let Some(idx) = content[start..].find(needle) else { + panic!( + "failed to find occurrence {} of substring {:?}", + occurrence, needle + ); + }; + found_at = Some(start + idx); + start = start + idx + needle.len(); + } + let byte_index = found_at.expect("occurrence lookup should set found_at"); + point_from_byte_index(content, byte_index) + } + + fn point_for_substring(content: &str, needle: &str) -> Point { + point_for_substring_n(content, needle, 1) + } + + fn point_from_byte_index(content: &str, byte_index: usize) -> Point { + assert!( + byte_index <= content.len(), + "byte index out of bounds for content" + ); + let mut row = 0usize; + let mut column = 0usize; + for b in content.as_bytes().iter().take(byte_index) { + if *b == b'\n' { + row += 1; + column = 0; + } else { + column += 1; + } + } + Point { row, column } + } + + #[test] + fn test_get_keyword_and_value() { + let (not, keyword, values) = get_keyword_and_value("ClientDataType = longvarchar"); + let value = values.get(0).copied(); + assert!(!not); + assert_eq!(keyword, "clientdatatype"); + assert_eq!(value, Some("longvarchar")); + let (not, keyword, values) = get_keyword_and_value("ClientDataType=longvarchar"); + let value = values.get(0).copied(); + assert!(!not); + assert_eq!(keyword, "clientdatatype"); + assert_eq!(value, Some("longvarchar")); + let (not, keyword, values) = get_keyword_and_value("ProcedureBlock = 1"); + let value = values.get(0).copied(); + assert!(!not); + assert_eq!(keyword, "procedureblock"); + assert_eq!(value, Some("1")); + let (not, keyword, _) = get_keyword_and_value("Not ProcedureBlock"); + assert!(not); + assert_eq!(keyword, "procedureblock"); + } + + #[tokio::test] + async fn test_goto_def_routines() { + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("gotodef"); + let (backend, uri) = setup_backend_and_workspace(project_root).await; + let project_state = backend.get_project(&uri).unwrap(); + let project_data = project_state.data.read(); + + eprintln!("CLASSES: {:#?}", project_data.classes.clone()); + eprintln!("VARIABLES: {:#?}", project_data.pub_var_defs.clone()) + } + + #[tokio::test] + async fn test_variables() { + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("variables"); + let document_url = Url::from_file_path( + env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("variables") + .join("testing-variable-building.cls"), + ) + .unwrap(); + let (backend, uri) = setup_backend_and_workspace(project_root).await; + let project_state = backend.get_project(&uri).unwrap(); + + let (tree, content, before_public_variables, before_classes) = { + let project_data = project_state.data.read(); + let (_, content, _, tree) = project_data.get_document_info(&document_url).unwrap(); + ( + tree, + content, + project_data.pub_var_defs.clone(), + project_data.classes.clone(), + ) + }; + + assert_eq!(before_classes.len(), 3); + assert!(before_classes.contains_key("SuperClass")); + assert!(before_classes.contains_key("SubClassOne")); + assert!(before_classes.contains_key("SubClassTwo")); + + let superclass_id = before_classes.get("SuperClass").unwrap(); + let subclassone_id = before_classes.get("SubClassOne").unwrap(); + let subclasstwo_id = before_classes.get("SubClassTwo").unwrap(); + let mut superclass_count = 0; + let mut subclassone_count = 0; + let mut subclasstwo_count = 0; + let before_x = before_public_variables + .get("x") + .expect("missing public variable x"); + + for (method_ref, _) in before_x { + if method_ref.class == *superclass_id { + superclass_count += 1; + } else if method_ref.class == *subclassone_id { + subclassone_count += 1; + } else if method_ref.class == *subclasstwo_id { + subclasstwo_count += 1; + } + } + assert_eq!(superclass_count, 2); + assert_eq!(subclassone_count, 1); + assert_eq!(subclasstwo_count, 1); + let before_y = before_public_variables + .get("y") + .expect("missing public variable y"); + superclass_count = 0; + + for (method_ref, _) in before_y { + if method_ref.class == *superclass_id { + superclass_count += 1; + } + } + assert_eq!(before_y.len(), 2); + assert_eq!(superclass_count, 2); + + project_state.update_document(document_url, tree, FileType::Cls, 1, content.as_str()); + + let ( + after_public_variables, + after_classes, + super_class_id, + sub_one_class_id, + sub_two_class_id, + gsm_classes, + ) = { + let project_data = project_state.data.read(); + let super_class_id = *project_data + .classes + .get("SuperClass") + .expect("missing SuperClass id"); + let sub_one_class_id = *project_data + .classes + .get("SubClassOne") + .expect("missing SubClassOne id"); + let sub_two_class_id = *project_data + .classes + .get("SubClassTwo") + .expect("missing SubClassTwo id"); + ( + project_data.pub_var_defs.clone(), + project_data.classes.clone(), + super_class_id, + sub_one_class_id, + sub_two_class_id, + project_data.global_semantic_model.classes.clone(), + ) + }; + + assert_eq!(after_classes.len(), 3); + assert!(after_classes.contains_key("SuperClass")); + assert!(after_classes.contains_key("SubClassOne")); + assert!(after_classes.contains_key("SubClassTwo")); + + let after_x = after_public_variables + .get("x") + .expect("missing public variable x after update"); + superclass_count = 0; + subclassone_count = 0; + subclasstwo_count = 0; + for (method_ref, _) in after_x { + if method_ref.class == super_class_id { + superclass_count += 1; + } else if method_ref.class == sub_one_class_id { + subclassone_count += 1; + } else if method_ref.class == sub_two_class_id { + subclasstwo_count += 1; + } + } + assert_eq!(superclass_count, 2); + assert_eq!(subclassone_count, 1); + assert_eq!(subclasstwo_count, 1); + let after_y = after_public_variables + .get("y") + .expect("missing public variable y after update"); + superclass_count = 0; + for (method_ref, _) in after_y { + if method_ref.class == super_class_id { + superclass_count += 1; + } + } + assert_eq!(after_y.len(), 2); + assert_eq!(superclass_count, 2); + let Some(sub_one_class_inherited) = gsm_classes.get(&sub_one_class_id) else { + panic!("Error: subclass one DNE in classes"); + }; + let Some(sub_two_class_inherited) = gsm_classes.get(&sub_two_class_id) else { + panic!("Error: subclass two DNE in classes"); + }; + + assert_eq!( + sub_one_class_inherited.inherited_classes, + vec![super_class_id] + ); + assert_eq!( + sub_two_class_inherited.inherited_classes, + vec![super_class_id] + ); + } + + #[tokio::test] + async fn test_class_keyword_inheritance() { + // KEYWORDS: language = objectscript, inheritance = right, Not ProcedureBlock + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("inheritance") + .join("class-keyword-inheritance.cls"); + let (backend, uri) = setup_backend_and_workspace(project_root).await; + let project_state = backend.get_project(&uri).unwrap(); + let project_data = project_state.data.read(); + let classes = project_data.classes.clone(); + let gsm = project_data.global_semantic_model.clone(); + for (_class_name, class_id) in classes { + let Some(class) = &gsm.classes.get(&class_id) else { + panic!("Class DNE"); + }; + assert_eq!(class.is_procedure_block, Some(false)); + assert_eq!(class.default_language, Some(Language::Objectscript)); + assert_eq!(class.inheritance_direction, "right"); + // get methods + for (_, method_ref) in class.methods.clone() { + let method = gsm.methods.get(&method_ref).unwrap(); + if method.name == "newVarChange" { + assert_eq!(method.variables.len(), 1); + let variable_refs = method.variables.get("x").unwrap(); + assert_eq!(variable_refs.len(), 1); + for variable_ref in variable_refs { + assert!(variable_ref.pub_id.is_none()); + assert!(variable_ref.priv_id.is_some()); + } + assert_eq!(method.is_procedure_block, Some(true)); + assert_eq!(method.language, Some(Language::Objectscript)); + } else { + let all_var_refs: Vec> = + method.variables.values().cloned().collect(); + for variable_refs in all_var_refs { + for variable_ref in variable_refs { + assert!(variable_ref.pub_id.is_some()); + assert!(variable_ref.priv_id.is_none()); + } + } + assert_eq!(method.is_procedure_block, Some(false)); + assert_eq!(method.language, Some(Language::Objectscript)); + } + } + } + } + + #[tokio::test] + // EC-GDEF-001 + async fn test_goto_definition_private_variable_procedure_block() { + let file_path = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("inheritance") + .join("class-keyword-inheritance.cls"); + let (backend, uri) = setup_backend_and_workspace(file_path.clone()).await; + let project_state = backend.get_project(&uri).expect("missing project state"); + let document_url = Url::from_file_path(file_path).expect("failed to create file url"); + let project_data = project_state.data.read(); + + let document = project_data + .documents + .get(&document_url) + .expect("missing class-keyword-inheritance document"); + let content = document.content.as_str(); + let use_point = point_for_substring(content, "w x"); + let set_point = point_for_substring(content, "set x = 2"); + + let locations = + project_data.get_variable_definition(&document_url, use_point, "x".to_string()); + + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].0, document_url); + assert_eq!(locations[0].1.start_point.row, set_point.row); + } + + #[tokio::test] + async fn test_goto_definition_public_variable_scope_and_workspace_resolution() { + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("variables"); + let document_url = Url::from_file_path( + env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("variables") + .join("testing-variable-building.cls"), + ) + .expect("failed to build document url"); + let (backend, uri) = setup_backend_and_workspace(project_root).await; + let project_state = backend.get_project(&uri).expect("missing project state"); + let project_data = project_state.data.read(); + + let document = project_data + .documents + .get(&document_url) + .expect("missing testing-variable-building document"); + let content = document.content.as_str(); + + // In multiplePubVarDefs, y is defined in the same method scope and should resolve to one symbol. + let y_use_point = point_for_substring_n(content, "w y", 3); + let y_def_point = point_for_substring(content, "set y = 3"); + let y_locations = + project_data.get_variable_definition(&document_url, y_use_point, "y".to_string()); + assert_eq!(y_locations.len(), 1); + assert_eq!(y_locations[0].0, document_url); + assert_eq!(y_locations[0].1.start_point.row, y_def_point.row); + + let superclass_id = *project_data + .classes + .get("SuperClass") + .expect("missing SuperClass"); + let dependent_names: HashSet = project_data + .dependent_class_index + .dependent_classes + .get(&superclass_id) + .expect("missing dependents for SuperClass") + .iter() + .filter_map(|class_id| { + project_data + .global_semantic_model + .get_class(&class_id) + .map(|class| class.name.clone()) + }) + .collect(); + assert!(dependent_names.contains("SubClassOne")); + assert!(dependent_names.contains("SubClassTwo")); + + // In multiplePubVarDefs, x is not in current scope, so workspace-wide public definitions are returned. + let x_use_point = point_for_substring_n(content, "w x", 2); + let x_locations = + project_data.get_variable_definition(&document_url, x_use_point, "x".to_string()); + assert_eq!(x_locations.len(), 2); + let paths: HashSet = x_locations + .into_iter() + .map(|(url, _)| url.path().to_string()) + .collect(); + assert!( + paths + .iter() + .any(|p| p.ends_with("testing-variable-building.cls")) + ); + assert!(paths.iter().any(|p| p.ends_with("subclass.cls"))); + } + + #[tokio::test] + async fn test_nested_refactor() { + let test_route = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("nested_dots"); + let actual_result_path = test_route.join("test-nested-refactor-actual.mac"); + let expected_result_path = test_route.join("test-nested-refactor-expected.mac"); + let test_mac_path = test_route.join("test-nested-refactor.mac"); + let test_mac_url = Url::from_file_path(&test_mac_path).unwrap(); + let (backend, uri) = setup_backend_and_workspace(test_route).await; + let project_state = backend.get_project(&uri).expect("missing project state"); + let project_data = project_state.data.read(); + let refactored = project_data + .refactor(RefactorLevel::DoCommands) + .into_iter() + .find_map(|(content, url)| (url == test_mac_url).then_some(content)) + .expect("missing planned refactor for test_nested_refactor.mac"); + std::fs::write(&actual_result_path, &refactored).unwrap(); + let actual_contents = std::fs::read_to_string(&actual_result_path).unwrap(); + let expected_contents = std::fs::read_to_string(expected_result_path).unwrap(); + assert_eq!(actual_contents, expected_contents) + } + + #[tokio::test] + async fn test_dotted_block() { + let test_route = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("dotted-block"); + let actual_result_path = test_route.join("test-dotted-block-actual.mac"); + let expected_result_path = test_route.join("test-dotted-block-expected.mac"); + let _ = std::fs::remove_file(&actual_result_path); + let test_mac_path = test_route.join("test-dotted-block.mac"); + let test_mac_url = Url::from_file_path(&test_mac_path).unwrap(); + let (backend, uri) = setup_backend_and_workspace(test_route).await; + let project_state = backend.get_project(&uri).expect("missing project state"); + let project_data = project_state.data.read(); + let refactored = project_data + .refactor(RefactorLevel::DoCommands) + .into_iter() + .find_map(|(content, url)| (url == test_mac_url).then_some(content)) + .expect("missing planned refactor for test_dotted_block.mac"); + std::fs::write(&actual_result_path, &refactored).unwrap(); + let actual_contents = std::fs::read_to_string(&actual_result_path).unwrap(); + let expected_contents = std::fs::read_to_string(expected_result_path).unwrap(); + assert_eq!(expected_contents, actual_contents); + } + + #[tokio::test] + async fn test_large_dotted_statements() { + let routines_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("local"); + let actual_result_path = routines_root.join("test-large-dotted-statements-actual.mac"); + let expected_result_path = routines_root.join("test-large-dotted-statements-expected.mac"); + let _ = std::fs::remove_file(&actual_result_path); + let test_mac_path = routines_root.join("test-large-dotted-statements.mac"); + let test_mac_url = Url::from_file_path(&test_mac_path).unwrap(); + let (backend, uri) = setup_backend_and_workspace(routines_root).await; + let project_state = backend.get_project(&uri).expect("missing project state"); + let project_data = project_state.data.read(); + let refactored = project_data + .refactor(RefactorLevel::DoCommands) + .into_iter() + .find_map(|(content, url)| (url == test_mac_url).then_some(content)) + .expect("missing planned refactor for test-refactor-do.mac"); + std::fs::write(&actual_result_path, &refactored).unwrap(); + let actual_content = std::fs::read_to_string(&actual_result_path).unwrap(); + let expected_content = std::fs::read_to_string(&expected_result_path).unwrap(); + assert_eq!(actual_content, expected_content,); + } + + #[tokio::test] + async fn test_refactor_do() { + let routines_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("routines"); + let actual_result_path = routines_root.join("test-refactor-do-actual.mac"); + let _ = std::fs::remove_file(&actual_result_path); + + let test_mac_path = routines_root.join("test-refactor-do.mac"); + let test_mac_url = Url::from_file_path(&test_mac_path).unwrap(); + + let (backend, uri) = setup_backend_and_workspace(routines_root).await; + let project_state = backend.get_project(&uri).expect("missing project state"); + let project_data = project_state.data.read(); + let refactored = project_data + .refactor(RefactorLevel::DoCommands) + .into_iter() + .find_map(|(content, url)| (url == test_mac_url).then_some(content)) + .expect("missing planned refactor for test-refactor-do.mac"); + let tree = parse_routine(refactored.as_str()); + std::fs::write(&actual_result_path, &refactored).unwrap(); + let contents = + std::fs::read_to_string("objectscript-tests/routines/test-refactor-do-expected.mac") + .unwrap(); + let expected_tree = parse_routine(contents.as_str()); + assert_eq!( + tree.root_node().to_sexp(), + expected_tree.root_node().to_sexp(), + ); + } + + #[test] + fn refactors_old_dotted_do_into_generated_subroutines() { + let input = r#"ROUTINE test + +check() private + set x = 2 + set y = 5 + i x = 2 d d okay + . set x = 1.2 + . set y = 1.2 + . w !,"goodbye" d + . . new x + . . set x = 250 + . quit + w !,"again x=",x + w !,"y=",y + w !,"leaving" + quit + +after + quit +"#; + let expected = r#"ROUTINE test + +check() private + set x = 2 + set y = 5 + i x = 2 do checkSubroutine1 d okay + w !,"again x=",x + w !,"y=",y + w !,"leaving" + quit + +checkSubroutine1 + set x = 1.2 + set y = 1.2 + w !,"goodbye" + do checkSubroutine2 + quit + +checkSubroutine2 + new x + set x = 250 + quit + +after + quit +"#; + + let actual = refactor_legacy_do_statements(input); + assert_eq!(actual, expected); + } + + #[test] + fn refactors_old_if_into_braced_block() { + let input = r#"ROUTINE test + +check() + i x = 2 set y = 5 set z = 6 + quit +"#; + let expected = r#"ROUTINE test + +check() + i x = 2 { + set y = 5 + set z = 6 + } + quit +"#; + + let actual = refactor_conditionals(input, FileType::Routine); + assert_eq!(actual, expected); + } + + #[test] + fn refactors_nested_old_if_into_consistently_indented_blocks() { + let input = r#"ROUTINE test + +check() + i x = 2 w hi i y = 5 w goodbye + quit +"#; + let expected = r#"ROUTINE test + +check() + i x = 2 { + w hi + i y = 5 { + w goodbye + } + } + quit +"#; + + let actual = refactor_conditionals(input, FileType::Routine); + assert_eq!(actual, expected); + } + + #[tokio::test] + // EC-GIMP-002 + async fn test_goto_implementation_returns_all_subclass_overrides() { + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("variables"); + + let (backend, uri) = setup_backend_and_workspace(project_root).await; + let project_state = backend.get_project(&uri).expect("missing project state"); + let project_data = project_state.data.read(); + let method_ref = project_data + .method_defs + .get("SuperClass") + .and_then(|m| m.get("newVarChange")) + .unwrap(); + let locations = project_data.get_method_overrides(method_ref); + assert_eq!(locations.len(), 2); + let paths: HashSet = locations + .into_iter() + .map(|(url, _)| url.path().to_string()) + .collect(); + assert!(paths.iter().any(|p| p.ends_with("subclass.cls"))); + assert!(paths.iter().any(|p| p.ends_with("subclass_two.cls"))); + } + + #[tokio::test] + // EC-GIMP-003 + async fn test_goto_implementation_includes_private_override() { + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("navigation") + .join("implementation"); + let (backend, uri) = setup_backend_and_workspace(project_root).await; + let project_state = backend.get_project(&uri).expect("missing project state"); + let project_data = project_state.data.read(); + let method_ref = project_data + .method_defs + .get("Demo.NavSuper") + .and_then(|m| m.get("Overridden")) + .unwrap(); + // eprintln!("METHOD: {:#?}", method); + // eprintln!("OVERRIDE INDEX: {:#?}", project_data.override_index); + let locations = project_data.get_method_overrides(method_ref); + + assert_eq!(locations.len(), 2); + let paths: HashSet = locations + .into_iter() + .map(|(url, _)| url.path().to_string()) + .collect(); + assert!(paths.iter().any(|p| p.ends_with("sub_public.cls"))); + assert!(paths.iter().any(|p| p.ends_with("sub_private.cls"))); + } + + #[tokio::test] + // EC-GDEF-002: Simulates what happens when did_open fires for sub_public BEFORE + // workspace indexing completes (super.cls not yet known) + async fn test_goto_definition_from_subclass_override_to_superclass() { + use objectscript_core::parse_structures::FileType; + + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("navigation") + .join("implementation"); + + let state = ProjectState::new(); + if state + .project_root_path + .set(Some(project_root.clone())) + .is_err() + { + eprintln!("failed to set the root path"); + } + let backend = BackendTester::new(); + let uri = Url::from_file_path(project_root.clone()).unwrap(); + backend.add_project(uri.clone(), state); + let project_state = backend.get_project(&uri).expect("missing project state"); + + let sub_public_url = Url::from_file_path(project_root.join("sub_public.cls")).unwrap(); + let sub_private_url = Url::from_file_path(project_root.join("sub_private.cls")).unwrap(); + let super_url = Url::from_file_path(project_root.join("super.cls")).unwrap(); + + // Simulate: user opens sub_public.cls FIRST (before workspace is indexed) + let sub_public_content = + std::fs::read_to_string(project_root.join("sub_public.cls")).unwrap(); + project_state.handle_document_opened( + sub_public_url.clone(), + sub_public_content.clone(), + FileType::Cls, + 1, + ); + + // Now simulate workspace indexing completing (super.cls and sub_private.cls get added) + let super_content = std::fs::read_to_string(project_root.join("super.cls")).unwrap(); + project_state.handle_document_opened(super_url.clone(), super_content, FileType::Cls, 1); + let sub_private_content = + std::fs::read_to_string(project_root.join("sub_private.cls")).unwrap(); + project_state.handle_document_opened( + sub_private_url.clone(), + sub_private_content, + FileType::Cls, + 1, + ); + + // Simulate user making an edit to sub_public (which triggers update_document) + { + let data = project_state.data.read(); + let (file_type, _, version, tree) = data + .get_document_info(&sub_public_url) + .expect("sub_public should exist"); + drop(data); + project_state.update_document( + sub_public_url.clone(), + tree, + file_type, + version, + sub_public_content.as_str(), + ); + } + + // Now check goto definition from each subclass method + let project_data = project_state.data.read(); + + let sub_public_class_id = project_data + .documents + .get(&sub_public_url) + .and_then(|d| d.class_id) + .expect("sub_public should have class_id"); + let sub_private_class_id = project_data + .documents + .get(&sub_private_url) + .and_then(|d| d.class_id) + .expect("sub_private should have class_id"); + + // Test goto definition from sub_public's Overridden -> super's Overridden + let locations_public = + project_data.get_method_superclass("Overridden".to_string(), &sub_public_class_id); + assert!( + !locations_public.is_empty(), + "sub_public's Overridden should resolve to super's Overridden (after late indexing)" + ); + assert!(locations_public[0].0.path().ends_with("super.cls")); + + // Test goto definition from sub_private's Overridden -> super's Overridden + let locations_private = + project_data.get_method_superclass("Overridden".to_string(), &sub_private_class_id); + assert!( + !locations_private.is_empty(), + "sub_private's Overridden should resolve to super's Overridden" + ); + assert!(locations_private[0].0.path().ends_with("super.cls")); + } + + #[tokio::test] + async fn test_routine_goto_definition_variable() { + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("gotodef"); + let (backend, uri) = setup_backend_and_workspace(project_root.clone()).await; + let project_state = backend.get_project(&uri).expect("missing project state"); + let project_data = project_state.data.read(); + + let routine_url = Url::from_file_path(project_root.join("test-routine-goto.mac")).unwrap(); + let document = project_data + .documents + .get(&routine_url) + .expect("routine doc exists"); + + eprintln!("class_id: {:?}", document.class_id); + eprintln!("class_name: {:?}", document.class_name); + eprintln!("scope_tree: {:#?}", document.scope_tree); + // "w x" is on line 8 (0-indexed row=7), x is at column 3 + let point = Point { row: 7, column: 3 }; + + let method_name = document.scope_tree.get_method_name(point); + eprintln!("method_name at point {:?}: {:?}", point, method_name); + + let locations = project_data.get_variable_definition(&routine_url, point, "x".to_string()); + eprintln!("variable locations: {:?}", locations); + assert!( + !locations.is_empty(), + "should find x definition in gotosubroutine" + ); + } +} diff --git a/src/objectscript.rs b/src/objectscript.rs new file mode 100644 index 0000000..d7f08e4 --- /dev/null +++ b/src/objectscript.rs @@ -0,0 +1,176 @@ +use std::fs; + +use zed_extension_api::{ + self as zed, + settings::LspSettings, + LanguageServerId, + Result, +}; + +struct ObjectScriptBinary { + path: String, + args: Option>, +} + +struct ObjectScriptExtension { + cached_binary_path: Option, +} + +impl ObjectScriptExtension { + fn language_server_binary( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result { + // Read user/worktree LSP settings for this language server id ("objectscript-lsp") + let settings = LspSettings::for_worktree(language_server_id.as_ref(), worktree); + // Optional user override: explicit binary path + args from settings + let binary = settings.ok().and_then(|settings| settings.binary); + let args = binary.as_ref().and_then(|binary| binary.arguments.clone()); + // Binary resolution order: + // 1) settings.binary.path + // 2) PATH lookup in the worktree environment + // 3) Zed-managed download from GitHub releases + let path = if let Some(path) = binary + .and_then(|b| b.path) + .or_else(|| worktree.which("objectscript-lsp")) + { + path + } else { + self.zed_managed_binary_path(language_server_id)? + }; + Ok(ObjectScriptBinary { path, args }) + } + + fn zed_managed_binary_path(&mut self, language_server_id: &LanguageServerId) -> Result { + if let Some(path) = &self.cached_binary_path { + if fs::metadata(path).is_ok_and(|m| m.is_file()) { + return Ok(path.clone()); + } + } + + zed::set_language_server_installation_status( + language_server_id, + &zed::LanguageServerInstallationStatus::CheckingForUpdate, + ); + + let release = zed::latest_github_release( + "hkimura-intersys/zed-objectscript", + zed::GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (platform, arch) = zed::current_platform(); + let asset_name = format!( + "objectscript-lsp-{version}-{os}-{arch}.{extension}", + version = release.version, + os = match platform { + zed::Os::Mac => "darwin", + zed::Os::Linux => "linux", + zed::Os::Windows => "win32", + }, + arch = match arch { + zed::Architecture::Aarch64 => "arm64", + zed::Architecture::X8664 => "x64", + zed::Architecture::X86 => return Err("unsupported platform x86".into()), + }, + extension = match platform { + zed::Os::Mac | zed::Os::Linux => "tar.gz", + zed::Os::Windows => "zip", + }, + ); + + let asset = release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| format!("no asset found matching {asset_name:?}"))?; + + let version_dir = format!("objectscript-lsp-{}", release.version); + let binary_path = format!( + "{version_dir}/bin/objectscript-lsp{extension}", + extension = match platform { + zed::Os::Mac | zed::Os::Linux => "", + zed::Os::Windows => ".exe", + }, + ); + + if !fs::metadata(&binary_path).is_ok_and(|stat| stat.is_file()) { + zed::set_language_server_installation_status( + language_server_id, + &zed::LanguageServerInstallationStatus::Downloading, + ); + + zed::download_file( + &asset.download_url, + &version_dir, + match platform { + zed::Os::Mac | zed::Os::Linux => zed::DownloadedFileType::GzipTar, + zed::Os::Windows => zed::DownloadedFileType::Zip, + }, + ) + .map_err(|e| format!("failed to download file: {e}"))?; + + let entries = + fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?; + if entry.file_name().to_str() != Some(&version_dir) { + fs::remove_dir_all(entry.path()).ok(); + } + } + } + + self.cached_binary_path = Some(binary_path.clone()); + Ok(binary_path) + } +} + +impl zed::Extension for ObjectScriptExtension { + fn new() -> Self { + Self { + cached_binary_path: None, + } + } + + fn language_server_command( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result { + let objectscript_binary = self.language_server_binary(language_server_id, worktree)?; + Ok(zed::Command { + command: objectscript_binary.path, + args: objectscript_binary.args.unwrap_or_else(std::vec::Vec::new), + env: vec![], + }) + } + + // This forwards Config (enable_snippets / enable_formatting / enable_lint / enable_strict_mode) + // from Zed settings into the LSP initialize request as `initializationOptions`. + fn language_server_initialization_options( + &mut self, + server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result> { + LspSettings::for_worktree(server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.initialization_options.clone()) + } + + // a separate channel of configuration that is NOT initializationOptions. + // Many servers use this for workspace/didChangeConfiguration. + fn language_server_workspace_configuration( + &mut self, + server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result> { + LspSettings::for_worktree(server_id.as_ref(), worktree) + .map(|lsp_settings| lsp_settings.settings.clone()) + } +} + +zed::register_extension!(ObjectScriptExtension); + +// TODO : I might want to set label_for_completion and label_for_symbol