diff --git a/.gitignore b/.gitignore index 20ab4e1..f7a375a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ Cargo.lock vscode/ objectscript-lsp/objectscript-tests/local/ objectscript-lsp/documentation/code-changes-AI +objectscript-lsp/documentation/local +objectscript-lsp/claude-code-objectscript-lsp/ +objectscript-lsp/.claude-plugin/ diff --git a/README.md b/README.md index d40ecb9..6fec0d5 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,41 @@ An [ObjectScript](https://docs.intersystems.com/latest/csp/docbook/DocBook.UI.Pa # Introduction -This Zed extension uses the [tree-sitter-objectscript](https://github.com/intersystems/tree-sitter-objectscript) grammars and the `objectscript-lsp` crate (local to this repo) to provide syntax highlighting, code injections, and language support for `.cls`, `.mac`, `.rtn`, `.inc`, `.int` and `.xml` files containing ObjectScript. Install the following extensions below to get syntax highlighting for any code injected into objectscript that is `sql` or `html`. +This Zed extension uses the [tree-sitter-objectscript](https://github.com/intersystems/tree-sitter-objectscript) grammars and the `objectscript-lsp` crate (local to this repo) to provide syntax highlighting, code injections, and language support for `.cls`, `.mac`, `.rtn`, `.inc`, `.int` and `.xml` files containing ObjectScript. Install the following extensions below to get syntax highlighting for any code injected into objectscript that is `sql`, `html`, or special comments (TODO, NOTE, etc). - [SQL](https://zed.dev/extensions/sql) - [HTML](https://zed.dev/extensions/html) +- [Comment](https://zed.dev/extensions/comment) The current features supported in the `ObjectScript language server` are `goto_definition`, `goto_implementation`, `refactor`, and `diagnostics`. These features are described in detail in the `objectscript-lsp/documentation/features` folder. +# Setup Dev Extension +If you want the most up-to-date version of this extension (including experimental parts that have not yet been merged into [zed-industries/extensions](https://github.com/zed-industries/extensions), follow these steps: +1. Make sure you have [rust](https://rust-lang.org/tools/install/) installed. +2. Clone this repository into your local folders and cd into the objectscript-lsp folder (`cd zed-objectscript/objectscript-lsp`). +3. Build a local copy of the binary: `cargo b && cargo install --path . --force` +4. Go to Zed, and do `Cmd + Shift + P`, and then choose `zed:Extensions`. Then choose `Install Dev Extension` and point it at your local copy of this directory. +5. NOTE: If rebuilding, sometimes you have to quit out of Zed and re-enter it for the changes to show. + + +# User Config Settings +Right now we have a `strict mode` setting. If true, diagnostics will send warnings for any class or method not defined in the current workspace. Additionally, duplicate class names/ method names in the same class will be filed as a diagnostic. If false, only syntax errors will be filed in diagnostics. + +The default is true. To set it to false, go to the Zed settings.json: (Cmd + Shift + P -> settings -> open settings.json) +and add this: +```json +"lsp": { + "objectscript-lsp": { + "initialization_options": { + "enableStrictMode": false + }, + "settings": { + "enableStrictMode": false + } + } + }, +``` + ## Reporting Issues Please report issues via [GitHub Issues](https://github.com/intersystems/zed-objectscript/issues). diff --git a/extension.toml b/extension.toml index 8b56c61..cb96e41 100644 --- a/extension.toml +++ b/extension.toml @@ -8,17 +8,17 @@ repository = "https://github.com/intersystems/zed-objectscript" [grammars.objectscript_udl] repository = "https://github.com/intersystems/tree-sitter-objectscript" -commit = "30cfb7addc4c6fdfcf1550180eef2cdcd1fdb202" +commit = "58432d03bfba25efad3059a52d0916d714739b09" path = "udl" [grammars.objectscript_routine] repository = "https://github.com/intersystems/tree-sitter-objectscript" -commit = "30cfb7addc4c6fdfcf1550180eef2cdcd1fdb202" +commit = "58432d03bfba25efad3059a52d0916d714739b09" path = "objectscript_routine" [grammars.objectscript] repository = "https://github.com/intersystems/tree-sitter-objectscript" -commit = "30cfb7addc4c6fdfcf1550180eef2cdcd1fdb202" +commit = "58432d03bfba25efad3059a52d0916d714739b09" path = "objectscript" [grammars.xml] diff --git a/languages/objectscript/highlights.scm b/languages/objectscript/highlights.scm index fda7a41..7107b02 100644 --- a/languages/objectscript/highlights.scm +++ b/languages/objectscript/highlights.scm @@ -5,6 +5,8 @@ [ (json_boolean_literal) (json_null_literal) + (false) + (true) ] @boolean (json_object_literal_pair @@ -250,18 +252,6 @@ (html_marker_reversed) ] @punctuation.special -[ - (line_comment_1) - (line_comment_2) - (line_comment_3) - (line_comment_4) - (block_comment) - (inline_comment) - (argumentless_inline_comment) - (pound_if_special_case_else) - (pound_if_special_case) -] @comment - (tag) @function "--" @operator @@ -337,9 +327,8 @@ (xdata_keyword) (xdata_keyword_mimetype) (property_keyword) -] @attribute +] @type.builtin -(documatic_line) @comment.doc [ (query_name) diff --git a/languages/objectscript/injections.scm b/languages/objectscript/injections.scm index 161ca8e..94c8c91 100644 --- a/languages/objectscript/injections.scm +++ b/languages/objectscript/injections.scm @@ -27,11 +27,8 @@ ; Keywords, one of type language = "python", none of type codemode ; External method body injection based on [ Language = ... ] (method_definition - (method_keyword_external_language - (typename) @_lang) - (external_method_body_content) @injection.content + (python_method_body_content) @injection.content (#set! injection.include-children "true") - (#any-of? @_lang "python" "Python" "PYTHON") (#set! injection.language "python")) (method_definition @@ -51,11 +48,8 @@ (#set! injection.language "ispl")) (trigger - (method_keyword_external_language - (typename) @_lang) - (external_method_body_content) @injection.content + (python_method_body_content) @injection.content (#set! injection.include-children "true") - (#any-of? @_lang "python" "Python" "PYTHON") (#set! injection.language "python")) (trigger @@ -147,4 +141,16 @@ (#set! injection.language "xml") (#set! injection.include-children "true")) + ([ + (line_comment_1) + (line_comment_2) + (line_comment_3) + (line_comment_4) + (block_comment) + (inline_comment) + (argumentless_inline_comment) + (documatic_line) + ] @injection.content + (#set! injection.language "comment")) + ; === END LOCAL === diff --git a/languages/objectscript_routine/highlights.scm b/languages/objectscript_routine/highlights.scm index da3ac78..bed8b8d 100644 --- a/languages/objectscript_routine/highlights.scm +++ b/languages/objectscript_routine/highlights.scm @@ -253,13 +253,6 @@ ] @punctuation.special [ - (line_comment_1) - (line_comment_2) - (line_comment_3) - (line_comment_4) - (block_comment) - (inline_comment) - (argumentless_inline_comment) (pound_if_special_case_else) (pound_if_special_case) ] @comment diff --git a/languages/objectscript_routine/injections.scm b/languages/objectscript_routine/injections.scm index 674713d..05a9fff 100644 --- a/languages/objectscript_routine/injections.scm +++ b/languages/objectscript_routine/injections.scm @@ -17,3 +17,14 @@ (embedded_xml (angled_bracket_fenced_text) @injection.content (#set! injection.language "xml")) + + ([ + (line_comment_1) + (line_comment_2) + (line_comment_3) + (line_comment_4) + (block_comment) + (inline_comment) + (argumentless_inline_comment) + ] @injection.content + (#set! injection.language "comment")) diff --git a/languages/objectscript_udl/highlights.scm b/languages/objectscript_udl/highlights.scm index e1676a4..0fb74ab 100644 --- a/languages/objectscript_udl/highlights.scm +++ b/languages/objectscript_udl/highlights.scm @@ -5,6 +5,8 @@ [ (json_boolean_literal) (json_null_literal) + (false) + (true) ] @boolean (json_object_literal_pair @@ -84,7 +86,15 @@ ] @variant (method_arg) @variable.parameter - +[(line_comment_1) +(line_comment_2) +(line_comment_3) +(line_comment_4) +(block_comment) +(inline_comment) +(argumentless_inline_comment) +] @comment +(documatic_line) @comment.doc ; I didn't include ( or ) in this, because they are often grouped ; as part of a sequence that gets turned into a single token, so they ; don't get matched, and one ends up getting colored differently than the other. @@ -251,13 +261,6 @@ ] @punctuation.special [ - (line_comment_1) - (line_comment_2) - (line_comment_3) - (line_comment_4) - (block_comment) - (inline_comment) - (argumentless_inline_comment) (pound_if_special_case_else) (pound_if_special_case) ] @comment @@ -297,6 +300,8 @@ ; === BEGIN LOCAL === (iris_username) @preproc +(keyword_class) @attribute + [ (keyword_import) (keyword_include) @@ -337,9 +342,7 @@ (xdata_keyword) (xdata_keyword_mimetype) (property_keyword) -] @attribute - -(documatic_line) @comment.doc +] @type.builtin [ (query_name) diff --git a/languages/objectscript_udl/injections.scm b/languages/objectscript_udl/injections.scm index 161ca8e..4dc5b18 100644 --- a/languages/objectscript_udl/injections.scm +++ b/languages/objectscript_udl/injections.scm @@ -27,11 +27,8 @@ ; Keywords, one of type language = "python", none of type codemode ; External method body injection based on [ Language = ... ] (method_definition - (method_keyword_external_language - (typename) @_lang) - (external_method_body_content) @injection.content + (python_method_body_content) @injection.content (#set! injection.include-children "true") - (#any-of? @_lang "python" "Python" "PYTHON") (#set! injection.language "python")) (method_definition @@ -51,11 +48,8 @@ (#set! injection.language "ispl")) (trigger - (method_keyword_external_language - (typename) @_lang) - (external_method_body_content) @injection.content + (python_method_body_content) @injection.content (#set! injection.include-children "true") - (#any-of? @_lang "python" "Python" "PYTHON") (#set! injection.language "python")) (trigger @@ -148,3 +142,15 @@ (#set! injection.include-children "true")) ; === END LOCAL === + +([ +(line_comment_1) +(line_comment_2) +(line_comment_3) +(line_comment_4) +(block_comment) +(inline_comment) +(argumentless_inline_comment) +(documatic_line) +] @injection.content + (#set! injection.language "comment")) diff --git a/objectscript-lsp/Cargo.toml b/objectscript-lsp/Cargo.toml index fde810a..8bf2118 100644 --- a/objectscript-lsp/Cargo.toml +++ b/objectscript-lsp/Cargo.toml @@ -13,9 +13,10 @@ 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.13" -tree-sitter-objectscript-routine = "1.9.13" -tree-sitter-objectscript-playground = "1.9.13" +tree-sitter-objectscript = "1.9.16" +tree-sitter-objectscript-routine = "1.9.16" +tree-sitter-objectscript-playground = "1.9.16" +tree-sitter-xml = "0.7.0" serde = "1.0.228" serde_json = "1.0.145" parking_lot = "0.12.5" diff --git a/objectscript-lsp/README.md b/objectscript-lsp/README.md index 788a943..36daade 100644 --- a/objectscript-lsp/README.md +++ b/objectscript-lsp/README.md @@ -1,14 +1,15 @@ # ObjectScript LSP -Language Server Protocol implementation for InterSystems ObjectScript using `tower-lsp` and `tree-sitter`. +Language Server & 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. +We built this language server to 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` +- Everything is rebuilt incrementally. - 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-definition for ObjectScript variables, orefs, methods, properties, classes, parameters 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 @@ -18,6 +19,7 @@ Goal: provide editor-independent ObjectScript semantics for VS Code, Zed, Neovim - Legacy `FOR` rewrites - document-scoped and workspace-scoped edits - Inheritance modeling and override index build +- Dependency modeling and dependencyGraph build (shows all paths to a given method) ## Architecture Summary @@ -44,6 +46,10 @@ Goal: provide editor-independent ObjectScript semantics for VS Code, Zed, Neovim - `textDocument/diagnostic` - Code actions and execute commands for refactor rewrites +## Configuration + +Editor-specific configuration examples for Zed, Neovim, and VS Code are documented in [documentation/configuration.md](documentation/configuration.md). + ## Build and Test @@ -70,9 +76,9 @@ In this case, the `DependencyGraph` is used to determine all possible paths to t ## Grammar Baseline - `tree-sitter = 0.26.6` -- `tree-sitter-objectscript = 1.9.13` -- `tree-sitter-objectscript-routine = 1.9.13` -- `tree-sitter-objectscript-playground = 1.9.13` +- `tree-sitter-objectscript = 1.9.16` +- `tree-sitter-objectscript-routine = 1.9.16` +- `tree-sitter-objectscript-playground = 1.9.16` - `tree-sitter-xml = 0.7.0` ## Roadmap diff --git a/objectscript-lsp/crates/objectscript-core/Cargo.toml b/objectscript-lsp/crates/objectscript-core/Cargo.toml index 48b3583..0daa3bc 100644 --- a/objectscript-lsp/crates/objectscript-core/Cargo.toml +++ b/objectscript-lsp/crates/objectscript-core/Cargo.toml @@ -6,12 +6,21 @@ authors = ["Hannah Kimura "] license = "MIT" description = "Language Server for ObjectScript" +[features] +update-bench = [] + +[[bench]] +name = "update_document" +harness = false +required-features = ["update-bench"] + [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.13" -tree-sitter-objectscript-routine = "1.9.13" +tree-sitter-objectscript = "1.9.16" +tree-sitter-objectscript-routine = "1.9.16" +tree-sitter-objectscript-playground = "1.9.16" tree-sitter-xml = "0.7.0" serde = "1.0.228" serde_json = "1.0.145" @@ -20,3 +29,6 @@ walkdir = "2.5.0" regex = "1.12.3" petgraph = "0.8.3" evalexpr = "13.1.0" + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } diff --git a/objectscript-lsp/crates/objectscript-core/benches/update_document.rs b/objectscript-lsp/crates/objectscript-core/benches/update_document.rs new file mode 100644 index 0000000..a2979d8 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/benches/update_document.rs @@ -0,0 +1,588 @@ +use criterion::{BatchSize, BenchmarkId, Criterion, criterion_group, criterion_main}; +use objectscript_core::common::get_member_name_and_range_from_root; +use objectscript_core::config::Config; +use objectscript_core::dependency_tracker::{DependencyGraph, Dependents}; +use objectscript_core::global_semantic::GlobalSemanticModel; +use objectscript_core::override_index::OverrideIndex; +use objectscript_core::parse_structures::{ClassId, FileType}; +use objectscript_core::workspace::{ + ProjectData, full_update_document_call_count, reset_full_update_document_call_count, +}; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::hint::black_box; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tower_lsp::lsp_types::Url; +use tree_sitter::{InputEdit, Parser, Point, Range, Tree}; +use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; +use tree_sitter_objectscript_routine::LANGUAGE_OBJECTSCRIPT_ROUTINE; + +const LARGE_DOTTED_STATEMENTS_FIXTURE: &str = + "objectscript-tests/local/test-large-dotted-statements-full.mac"; + +#[derive(Clone)] +struct PreparedEdit { + old_content: String, + new_content: String, + input_edit: InputEdit, + old_tree: Tree, + new_tree: Tree, + changed_ranges: Vec, + url: Url, + file_type: FileType, + is_rtn: bool, + new_class_range: Range, + new_class_name: String, + new_class_name_def: Range, +} + +struct BenchConfig { + methods: usize, + body_lines: usize, +} + +fn bench_update_document(c: &mut Criterion) { + let inputs = prepared_inputs_from_env(); + let sample_size = env_usize("BENCH_SAMPLE_SIZE", 10).max(10); + let warmup_secs = env_usize("BENCH_WARMUP_SECS", 3).max(1); + let measurement_secs = env_usize("BENCH_MEASUREMENT_SECS", 10).max(1); + + let mut group = c.benchmark_group("update_document"); + group.sample_size(sample_size); + group.warm_up_time(Duration::from_secs(warmup_secs as u64)); + group.measurement_time(Duration::from_secs(measurement_secs as u64)); + + for (input_label, prepared) in inputs { + group.bench_function( + BenchmarkId::new("full_update_document", &input_label), + |bencher| { + bencher.iter_batched( + || { + let data = build_project_data(&prepared); + let class_id = *data + .classes + .get(&prepared.new_class_name) + .expect("benchmark document should be indexed before update"); + (data, class_id) + }, + |(mut data, class_id)| { + data.full_update_document( + black_box(prepared.url.clone()), + black_box(&prepared.new_content), + black_box(&prepared.new_tree), + black_box(prepared.file_type), + black_box(class_id), + black_box(prepared.new_class_name.clone()), + black_box(Some(2)), + black_box(prepared.new_class_range), + ); + black_box(data.method_defs.len()); + data + }, + BatchSize::PerIteration, + ); + }, + ); + + group.bench_function( + BenchmarkId::new("incremental_update_document", &input_label), + |bencher| { + bencher.iter_batched( + || build_project_data(&prepared), + |mut data| { + reset_full_update_document_call_count(); + data.incremental_update_document( + black_box(prepared.url.clone()), + black_box(&prepared.new_tree), + black_box(prepared.file_type), + black_box(2), + black_box(&prepared.new_content), + black_box(prepared.changed_ranges.clone()), + black_box(prepared.new_class_name.clone()), + black_box(prepared.new_class_range), + black_box(prepared.new_class_name_def), + ); + assert_eq!( + full_update_document_call_count(), + 0, + "incremental_update_document fell back to full_update_document" + ); + black_box(data.method_defs.len()); + data + }, + BatchSize::PerIteration, + ); + }, + ); + } + + group.finish(); +} + +fn bench_parse_document(c: &mut Criterion) { + let inputs = prepared_inputs_from_env(); + let sample_size = env_usize("BENCH_SAMPLE_SIZE", 10).max(10); + let warmup_secs = env_usize("BENCH_WARMUP_SECS", 3).max(1); + let measurement_secs = env_usize("BENCH_MEASUREMENT_SECS", 10).max(1); + + let mut group = c.benchmark_group("parse_document"); + group.sample_size(sample_size); + group.warm_up_time(Duration::from_secs(warmup_secs as u64)); + group.measurement_time(Duration::from_secs(measurement_secs as u64)); + + for (input_label, prepared) in inputs { + group.bench_function( + BenchmarkId::new("full_parse_document", &input_label), + |bencher| { + bencher.iter_batched( + || new_parser(prepared.file_type), + |mut parser| { + let tree = parser + .parse(black_box(prepared.new_content.as_str()), None) + .expect("new generated class should parse from scratch"); + assert!( + !tree.root_node().has_error(), + "full parse produced syntax errors" + ); + black_box(tree.root_node().end_byte()); + tree + }, + BatchSize::PerIteration, + ); + }, + ); + + group.bench_function( + BenchmarkId::new("incremental_parse_document", &input_label), + |bencher| { + bencher.iter_batched( + || { + let mut edited_old_tree = prepared.old_tree.clone(); + edited_old_tree.edit(&prepared.input_edit); + (new_parser(prepared.file_type), edited_old_tree) + }, + |(mut parser, edited_old_tree)| { + let tree = parser + .parse( + black_box(prepared.new_content.as_str()), + Some(black_box(&edited_old_tree)), + ) + .expect("new generated class should parse incrementally"); + assert!( + !tree.root_node().has_error(), + "incremental parse produced syntax errors" + ); + black_box(tree.root_node().end_byte()); + tree + }, + BatchSize::PerIteration, + ); + }, + ); + } + + group.finish(); +} + +fn build_project_data(prepared: &PreparedEdit) -> ProjectData { + let mut data = ProjectData { + config: Config::default(), + documents: HashMap::new(), + global_semantic_model: GlobalSemanticModel::new(), + classes: HashMap::new(), + method_defs: HashMap::new(), + property_defs: HashMap::new(), + parameter_defs: HashMap::new(), + pub_var_defs: HashMap::new(), + override_index: OverrideIndex::new(), + dependent_class_index: Dependents::new(), + dependency_graph: DependencyGraph::new(), + unresolved_inheritance_references: HashMap::new(), + unresolved_method_references: HashMap::new(), + inheritance_diagonstics: HashMap::new(), + method_reference_diagnostics: HashMap::new(), + other_class_diagnostics: HashMap::new(), + }; + + let (class_range, class_name, _class_name_def) = get_member_name_and_range_from_root( + &prepared.old_content, + prepared.old_tree.root_node(), + prepared.is_rtn, + ) + .expect("old generated class should have a class name"); + let class_id = ClassId(data.global_semantic_model.next_id()); + + data.add_document( + prepared.url.clone(), + &prepared.old_content, + &prepared.old_tree, + prepared.file_type, + Some(class_id), + class_name, + Some(1), + class_range, + ); + + data +} + +fn prepared_inputs_from_env() -> Vec<(String, PreparedEdit)> { + if let Ok(path) = env::var("BENCH_INPUT_FILE") { + let path = PathBuf::from(path); + let prepared = prepare_file_edit(&path); + let label = format!( + "file_{}_bytes_{}", + sanitize_label( + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("input") + ), + prepared.old_content.len() + ); + return vec![(label, prepared)]; + } + + if let Ok(preset) = env::var("BENCH_INPUT_PRESET") { + let path = input_preset_path(&preset); + let prepared = prepare_file_edit(&path); + let label = format!( + "preset_{}_bytes_{}", + sanitize_label(&preset), + prepared.old_content.len() + ); + return vec![(label, prepared)]; + } + + bench_configs_from_env() + .into_iter() + .map(|config| { + let prepared = prepare_synthetic_edit(&config); + let label = format!( + "methods_{}_body_lines_{}_bytes_{}", + config.methods, + config.body_lines, + prepared.old_content.len() + ); + (label, prepared) + }) + .collect() +} + +fn input_preset_path(preset: &str) -> PathBuf { + match preset { + "large_dotted_statements" | "large-dotted-statements" | "large_dotted" => { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(LARGE_DOTTED_STATEMENTS_FIXTURE) + } + other => { + panic!("unsupported BENCH_INPUT_PRESET {other:?}; expected \"large_dotted_statements\"") + } + } +} + +fn prepare_synthetic_edit(config: &BenchConfig) -> PreparedEdit { + assert!(config.methods > 1, "BENCH_METHODS must be greater than 1"); + + let old_content = make_large_class(config.methods, config.body_lines); + let target_method = config.methods / 2; + let old_marker = format!(" Set methodNumber = {target_method}\n"); + let new_marker = format!(" Set methodNumber = {target_method}\n Write methodNumber\n"); + + let marker_start = old_content + .find(&old_marker) + .expect("generated class should contain target edit marker"); + let marker_end = marker_start + old_marker.len(); + let new_content = replace_range(&old_content, marker_start, marker_end, &new_marker); + + let file_type = FileType::Cls; + let is_rtn = false; + let mut parser = new_parser(file_type); + + let old_tree = parser + .parse(&old_content, None) + .expect("old generated class should parse"); + assert!( + !old_tree.root_node().has_error(), + "old generated class parsed with syntax errors" + ); + + let edit = InputEdit { + start_byte: marker_start, + old_end_byte: marker_end, + new_end_byte: marker_start + new_marker.len(), + start_position: point_for_byte(&old_content, marker_start), + old_end_position: point_for_byte(&old_content, marker_end), + new_end_position: point_for_byte(&new_content, marker_start + new_marker.len()), + }; + let mut edited_old_tree = old_tree.clone(); + edited_old_tree.edit(&edit); + + let new_tree = parser + .parse(&new_content, Some(&edited_old_tree)) + .expect("new generated class should parse"); + assert!( + !new_tree.root_node().has_error(), + "new generated class parsed with syntax errors" + ); + + // The benchmark intentionally measures update-document work, not Tree-sitter's + // changed-range computation. Use the precise range of the synthetic statement + // insertion so incremental_update_document receives a small changed scope. + let changed_ranges = vec![Range { + start_byte: marker_start, + end_byte: marker_start + new_marker.len(), + start_point: point_for_byte(&new_content, marker_start), + end_point: point_for_byte(&new_content, marker_start + new_marker.len()), + }]; + + let (new_class_range, new_class_name, new_class_name_def) = + get_member_name_and_range_from_root(&new_content, new_tree.root_node(), is_rtn) + .expect("new generated class should have a class name"); + + let url = Url::from_file_path(env::temp_dir().join("objectscript-update-bench/Bench.Big.cls")) + .expect("benchmark URL should be a valid file URL"); + + PreparedEdit { + old_content, + new_content, + input_edit: edit, + old_tree, + new_tree, + changed_ranges, + url, + file_type, + is_rtn, + new_class_range, + new_class_name, + new_class_name_def, + } +} + +fn prepare_file_edit(path: &Path) -> PreparedEdit { + let file_type = infer_file_type(path); + let is_rtn = file_type == FileType::Routine; + let old_content = fs::read_to_string(path) + .unwrap_or_else(|error| panic!("failed to read benchmark input file {path:?}: {error}")); + let (insert_at, insertion) = synthetic_file_edit(&old_content, file_type); + let new_content = replace_range(&old_content, insert_at, insert_at, &insertion); + + let mut parser = new_parser(file_type); + let old_tree = parser + .parse(&old_content, None) + .unwrap_or_else(|| panic!("old benchmark input file {path:?} should parse")); + assert!( + !old_tree.root_node().has_error(), + "old benchmark input file {path:?} parsed with syntax errors" + ); + + let edit = InputEdit { + start_byte: insert_at, + old_end_byte: insert_at, + new_end_byte: insert_at + insertion.len(), + start_position: point_for_byte(&old_content, insert_at), + old_end_position: point_for_byte(&old_content, insert_at), + new_end_position: point_for_byte(&new_content, insert_at + insertion.len()), + }; + let mut edited_old_tree = old_tree.clone(); + edited_old_tree.edit(&edit); + + let new_tree = parser + .parse(&new_content, Some(&edited_old_tree)) + .unwrap_or_else(|| panic!("new benchmark input file {path:?} should parse")); + assert!( + !new_tree.root_node().has_error(), + "new benchmark input file {path:?} parsed with syntax errors" + ); + + let changed_ranges = vec![Range { + start_byte: insert_at, + end_byte: insert_at + insertion.len(), + start_point: point_for_byte(&new_content, insert_at), + end_point: point_for_byte(&new_content, insert_at + insertion.len()), + }]; + + let (new_class_range, new_class_name, new_class_name_def) = + get_member_name_and_range_from_root(&new_content, new_tree.root_node(), is_rtn) + .unwrap_or_else(|| panic!("benchmark input file {path:?} should have a member name")); + + let url = Url::from_file_path(path) + .unwrap_or_else(|_| panic!("benchmark input path {path:?} should be a valid file URL")); + + PreparedEdit { + old_content, + new_content, + input_edit: edit, + old_tree, + new_tree, + changed_ranges, + url, + file_type, + is_rtn, + new_class_range, + new_class_name, + new_class_name_def, + } +} + +fn infer_file_type(path: &Path) -> FileType { + match path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| extension.to_ascii_lowercase()) + .as_deref() + { + Some("cls") => FileType::Cls, + Some("mac") | Some("inc") | Some("rtn") | Some("int") => FileType::Routine, + other => panic!( + "unsupported BENCH_INPUT_FILE extension {other:?}; expected .cls or routine file" + ), + } +} + +fn synthetic_file_edit(content: &str, file_type: FileType) -> (usize, String) { + match file_type { + FileType::Routine => routine_file_edit(content), + FileType::Cls => class_file_edit(content), + FileType::Xml => panic!("XML benchmark inputs are not supported"), + } +} + +fn routine_file_edit(content: &str) -> (usize, String) { + let line_start = content[..content.len() / 2] + .rfind('\n') + .map_or(0, |idx| idx + 1); + let line_end = content[line_start..] + .find('\n') + .map_or(content.len(), |idx| line_start + idx + 1); + let line = &content[line_start..line_end]; + let prefix: String = line + .chars() + .take_while(|ch| matches!(ch, ' ' | '\t' | '.')) + .collect(); + let prefix = if prefix.is_empty() { + "\t".to_string() + } else { + prefix + }; + (line_end, format!("{prefix}; criterion benchmark edit\n")) +} + +fn class_file_edit(content: &str) -> (usize, String) { + let line_start = content[..content.len() / 2] + .rfind('\n') + .map_or(0, |idx| idx + 1); + let line_end = content[line_start..] + .find('\n') + .map_or(content.len(), |idx| line_start + idx + 1); + (line_end, " // criterion benchmark edit\n".to_string()) +} + +fn new_parser(file_type: FileType) -> Parser { + let mut parser = Parser::new(); + match file_type { + FileType::Cls => parser + .set_language(&LANGUAGE_OBJECTSCRIPT_UDL.into()) + .expect("failed to load ObjectScript UDL grammar"), + FileType::Routine => parser + .set_language(&LANGUAGE_OBJECTSCRIPT_ROUTINE.into()) + .expect("failed to load ObjectScript routine grammar"), + FileType::Xml => panic!("XML benchmark inputs are not supported"), + } + parser +} + +fn make_large_class(methods: usize, body_lines: usize) -> String { + let mut content = String::new(); + content.push_str("Class Bench.Big\n{\n"); + + for method_idx in 0..methods { + content.push_str(&format!("Method Method{method_idx}() As %Status\n{{\n")); + content.push_str(&format!(" Set methodNumber = {method_idx}\n")); + content.push_str(" Set total = 0\n"); + for line_idx in 0..body_lines { + content.push_str(&format!(" Set total = total + {line_idx}\n")); + } + content.push_str(" Write total\n"); + content.push_str(" Quit total\n"); + content.push_str("}\n"); + } + + content.push_str("}\n"); + content +} + +fn replace_range(input: &str, start: usize, end: usize, replacement: &str) -> String { + let mut output = String::with_capacity(input.len() - (end - start) + replacement.len()); + output.push_str(&input[..start]); + output.push_str(replacement); + output.push_str(&input[end..]); + output +} + +fn point_for_byte(text: &str, byte_index: usize) -> Point { + let mut row = 0; + let mut column = 0; + + for byte in text.as_bytes().iter().take(byte_index) { + if *byte == b'\n' { + row += 1; + column = 0; + } else { + column += 1; + } + } + + Point { row, column } +} + +fn bench_configs_from_env() -> Vec { + let body_lines = env_usize("BENCH_BODY_LINES", 12); + let methods = env::var("BENCH_METHODS_LIST") + .ok() + .map(|value| { + value + .split(',') + .filter_map(|part| part.trim().parse::().ok()) + .collect::>() + }) + .filter(|values| !values.is_empty()) + .unwrap_or_else(|| vec![env_usize("BENCH_METHODS", 100)]); + + methods + .into_iter() + .map(|methods| BenchConfig { + methods, + body_lines, + }) + .collect() +} + +fn env_usize(name: &str, default: usize) -> usize { + env::var(name) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(default) +} + +fn sanitize_label(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect() +} + +criterion_group! { + name = benches; + config = Criterion::default().configure_from_args(); + targets = bench_update_document, bench_parse_document +} +criterion_main!(benches); diff --git a/objectscript-lsp/crates/objectscript-core/examples/update_bench.rs b/objectscript-lsp/crates/objectscript-core/examples/update_bench.rs new file mode 100644 index 0000000..ceb5976 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/examples/update_bench.rs @@ -0,0 +1,390 @@ +use objectscript_core::common::{get_member_name_and_range_from_root, ts_range_to_lsp_range}; +use objectscript_core::config::Config; +use objectscript_core::dependency_tracker::{DependencyGraph, Dependents}; +use objectscript_core::global_semantic::GlobalSemanticModel; +use objectscript_core::override_index::OverrideIndex; +use objectscript_core::parse_structures::{ClassId, FileType}; +use objectscript_core::workspace::{ + ProjectData, full_update_document_call_count, reset_full_update_document_call_count, +}; +use std::collections::HashMap; +use std::env; +use std::hint::black_box; +use std::time::{Duration, Instant}; +use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Url}; +use tree_sitter::{InputEdit, Parser, Point, Range, Tree}; +use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; + +const CLASS_NAME: &str = "Bench.Big"; + +#[derive(Clone)] +struct PreparedEdit { + old_content: String, + new_content: String, + old_tree: Tree, + new_tree: Tree, + changed_ranges: Vec, + url: Url, + new_class_range: Range, + new_class_name: String, + new_class_name_def: Range, +} + +struct BenchConfig { + methods: usize, + body_lines: usize, + iterations: usize, + warmup: usize, +} + +#[derive(Debug)] +struct Stats { + min: Duration, + median: Duration, + mean: Duration, + max: Duration, +} + +fn main() { + if !cfg!(feature = "update-bench") { + eprintln!( + "Run with: cargo run -p objectscript-core --release --features update-bench --example update_bench" + ); + std::process::exit(2); + } + + let config = BenchConfig { + methods: env_usize("BENCH_METHODS", 250), + body_lines: env_usize("BENCH_BODY_LINES", 12), + iterations: env_usize("BENCH_ITERS", 5), + warmup: env_usize("BENCH_WARMUP", 2), + }; + + let prepared = prepare_edit(&config); + + println!( + "fixture: methods={} body_lines={} bytes={} changed_ranges={}", + config.methods, + config.body_lines, + prepared.old_content.len(), + prepared.changed_ranges.len() + ); + + let full = collect_samples(&prepared, &config, UpdateKind::Full); + let incremental = collect_samples(&prepared, &config, UpdateKind::Incremental); + + let full_stats = stats(&full); + let incremental_stats = stats(&incremental); + let speedup = full_stats.mean.as_nanos() as f64 / incremental_stats.mean.as_nanos() as f64; + + println!("update_document benchmark"); + println!(" setup excluded: parsing, changed_ranges, initial add_document"); + println!(" samples: {} warmup: {}", config.iterations, config.warmup); + println!(); + println!( + "{:<18} {:>12} {:>12} {:>12} {:>12}", + "strategy", "min", "median", "mean", "max" + ); + println!( + "{:<18} {:>12} {:>12} {:>12} {:>12}", + "full", + format_duration(full_stats.min), + format_duration(full_stats.median), + format_duration(full_stats.mean), + format_duration(full_stats.max) + ); + println!( + "{:<18} {:>12} {:>12} {:>12} {:>12}", + "incremental", + format_duration(incremental_stats.min), + format_duration(incremental_stats.median), + format_duration(incremental_stats.mean), + format_duration(incremental_stats.max) + ); + println!(); + println!("mean speedup: {:.2}x", speedup); +} + +#[derive(Clone, Copy)] +enum UpdateKind { + Full, + Incremental, +} + +fn collect_samples( + prepared: &PreparedEdit, + config: &BenchConfig, + update_kind: UpdateKind, +) -> Vec { + let mut samples = Vec::with_capacity(config.iterations); + let total_runs = config.warmup + config.iterations; + + for run_idx in 0..total_runs { + let mut data = build_project_data(prepared); + let class_id = *data + .classes + .get(CLASS_NAME) + .expect("benchmark class should be indexed before update"); + + reset_full_update_document_call_count(); + let start = Instant::now(); + match update_kind { + UpdateKind::Full => { + data.full_update_document( + black_box(prepared.url.clone()), + black_box(&prepared.new_content), + black_box(&prepared.new_tree), + black_box(FileType::Cls), + black_box(class_id), + black_box(prepared.new_class_name.clone()), + black_box(Some(2)), + black_box(prepared.new_class_range), + ); + } + UpdateKind::Incremental => { + data.incremental_update_document( + black_box(prepared.url.clone()), + black_box(&prepared.new_tree), + black_box(FileType::Cls), + black_box(2), + black_box(&prepared.new_content), + black_box(prepared.changed_ranges.clone()), + black_box(prepared.new_class_name.clone()), + black_box(prepared.new_class_range), + black_box(prepared.new_class_name_def), + ); + let fallback_calls = full_update_document_call_count(); + assert_eq!( + fallback_calls, 0, + "incremental_update_document fell back to full_update_document" + ); + } + } + let elapsed = start.elapsed(); + black_box(data.method_defs.len()); + + if run_idx >= config.warmup { + samples.push(elapsed); + } + } + + samples +} + +fn build_project_data(prepared: &PreparedEdit) -> ProjectData { + let mut data = ProjectData { + config: Config::default(), + documents: HashMap::new(), + global_semantic_model: GlobalSemanticModel::new(), + classes: HashMap::new(), + method_defs: HashMap::new(), + property_defs: HashMap::new(), + parameter_defs: HashMap::new(), + pub_var_defs: HashMap::new(), + override_index: OverrideIndex::new(), + dependent_class_index: Dependents::new(), + dependency_graph: DependencyGraph::new(), + unresolved_inheritance_references: HashMap::new(), + unresolved_method_references: HashMap::new(), + inheritance_diagonstics: HashMap::new(), + method_reference_diagnostics: HashMap::new(), + other_class_diagnostics: HashMap::new(), + }; + + let (class_range, class_name, new_class_name_def) = get_member_name_and_range_from_root( + &prepared.old_content, + prepared.old_tree.root_node(), + false, + ) + .expect("old generated class should have a class name"); + if data.classes.contains_key(&class_name) { + let lsp_range = ts_range_to_lsp_range(&prepared.old_content, new_class_name_def); + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::ERROR), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "A Class named {:?} already exists in this workspace.", + &class_name + ), + related_information: None, + tags: None, + data: None, + }; + data.other_class_diagnostics + .entry(prepared.url.clone()) + .or_insert(Vec::new()) + .push(diagnostic); + eprintln!( + "Error: A class with name {:?} already exists, aborting (incremental_update_document)", + &class_name + ); + } + let class_id = ClassId(data.global_semantic_model.next_id()); + + data.add_document( + prepared.url.clone(), + &prepared.old_content, + &prepared.old_tree, + FileType::Cls, + Some(class_id), + class_name, + Some(1), + class_range, + ); + + data +} + +fn prepare_edit(config: &BenchConfig) -> PreparedEdit { + assert!(config.methods > 1, "BENCH_METHODS must be greater than 1"); + + let old_content = make_large_class(config.methods, config.body_lines); + let target_method = config.methods / 2; + let old_marker = format!(" Set methodNumber = {target_method}\n"); + let new_marker = format!(" Set methodNumber = {target_method}\n Write methodNumber\n"); + + let marker_start = old_content + .find(&old_marker) + .expect("generated class should contain target edit marker"); + let marker_end = marker_start + old_marker.len(); + let new_content = replace_range(&old_content, marker_start, marker_end, &new_marker); + + let mut parser = Parser::new(); + parser + .set_language(&LANGUAGE_OBJECTSCRIPT_UDL.into()) + .expect("failed to load ObjectScript UDL grammar"); + + let old_tree = parser + .parse(&old_content, None) + .expect("old generated class should parse"); + assert!( + !old_tree.root_node().has_error(), + "old generated class parsed with syntax errors" + ); + + let edit = InputEdit { + start_byte: marker_start, + old_end_byte: marker_end, + new_end_byte: marker_start + new_marker.len(), + start_position: point_for_byte(&old_content, marker_start), + old_end_position: point_for_byte(&old_content, marker_end), + new_end_position: point_for_byte(&new_content, marker_start + new_marker.len()), + }; + let mut edited_old_tree = old_tree.clone(); + edited_old_tree.edit(&edit); + + let new_tree = parser + .parse(&new_content, Some(&edited_old_tree)) + .expect("new generated class should parse"); + assert!( + !new_tree.root_node().has_error(), + "new generated class parsed with syntax errors" + ); + + let changed_ranges = vec![Range { + start_byte: marker_start, + end_byte: marker_start + new_marker.len(), + start_point: point_for_byte(&new_content, marker_start), + end_point: point_for_byte(&new_content, marker_start + new_marker.len()), + }]; + + let (new_class_range, new_class_name, new_class_name_def) = + get_member_name_and_range_from_root(&new_content, new_tree.root_node(), false) + .expect("new generated class should have a class name"); + + let url = Url::from_file_path(env::temp_dir().join("objectscript-update-bench/Bench.Big.cls")) + .expect("benchmark URL should be a valid file URL"); + + PreparedEdit { + old_content, + new_content, + old_tree, + new_tree, + changed_ranges, + url, + new_class_range, + new_class_name, + new_class_name_def, + } +} + +fn make_large_class(methods: usize, body_lines: usize) -> String { + let mut content = String::new(); + content.push_str("Class Bench.Big\n{\n"); + + for method_idx in 0..methods { + content.push_str(&format!("Method Method{method_idx}() As %Status\n{{\n")); + content.push_str(&format!(" Set methodNumber = {method_idx}\n")); + content.push_str(" Set total = 0\n"); + for line_idx in 0..body_lines { + content.push_str(&format!(" Set total = total + {line_idx}\n")); + } + content.push_str(" Write total\n"); + content.push_str(" Quit total\n"); + content.push_str("}\n"); + } + + content.push_str("}\n"); + content +} + +fn replace_range(input: &str, start: usize, end: usize, replacement: &str) -> String { + let mut output = String::with_capacity(input.len() - (end - start) + replacement.len()); + output.push_str(&input[..start]); + output.push_str(replacement); + output.push_str(&input[end..]); + output +} + +fn point_for_byte(text: &str, byte_index: usize) -> Point { + let mut row = 0; + let mut column = 0; + + for byte in text.as_bytes().iter().take(byte_index) { + if *byte == b'\n' { + row += 1; + column = 0; + } else { + column += 1; + } + } + + Point { row, column } +} + +fn stats(samples: &[Duration]) -> Stats { + assert!(!samples.is_empty(), "no benchmark samples collected"); + + let mut sorted = samples.to_vec(); + sorted.sort(); + let total_nanos: u128 = sorted.iter().map(Duration::as_nanos).sum(); + let mean_nanos = total_nanos / sorted.len() as u128; + + Stats { + min: sorted[0], + median: sorted[sorted.len() / 2], + mean: Duration::from_nanos(mean_nanos as u64), + max: sorted[sorted.len() - 1], + } +} + +fn format_duration(duration: Duration) -> String { + let nanos = duration.as_nanos(); + if nanos >= 1_000_000 { + format!("{:.3} ms", nanos as f64 / 1_000_000.0) + } else if nanos >= 1_000 { + format!("{:.3} µs", nanos as f64 / 1_000.0) + } else { + format!("{nanos} ns") + } +} + +fn env_usize(name: &str, default: usize) -> usize { + env::var(name) + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(default) +} diff --git a/objectscript-lsp/crates/objectscript-core/src/class.rs b/objectscript-lsp/crates/objectscript-core/src/class.rs index d43830a..e2be388 100644 --- a/objectscript-lsp/crates/objectscript-core/src/class.rs +++ b/objectscript-lsp/crates/objectscript-core/src/class.rs @@ -1,21 +1,89 @@ use crate::common::{ - get_keyword_and_value, get_member_name_from_root, get_node_children, get_string_at_byte_range, + get_dotted_subroutine_info, get_keyword_and_value, get_node_children, get_parameter_name, + get_procedure_info, get_property_name, get_routine_method_range, get_string_at_byte_range, + get_subroutine_info, ts_range_to_lsp_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"; - } +use crate::parse_structures::{ + Class, ClassId, Language, MemberType, Method, MethodId, MethodRef, MethodType, Parameter, + ParameterId, ParameterRef, Property, PropertyId, PropertyRef, +}; +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; +use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Range as LspRange}; +use tree_sitter::{ + Language as TsLanguage, Node, Query, QueryCursor, Range, StreamingIterator, Tree, +}; +use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; +use tree_sitter_objectscript_routine::LANGUAGE_OBJECTSCRIPT_ROUTINE; + +const UDL_CLASS_HEADER_QUERY: &str = r#" +[ + (class_definition (class_extends (class_name (identifier) @inherits))) + (class_definition (class_keyword) @classkeyword) +]"#; + +const UDL_CLASS_MEMBER_QUERY: &str = r#"(class_definition + (class_body + (class_statement + [ + (method (method_definition) @method) + (classmethod (method_definition) @classmethod) + (parameter) @parameter + (property) @property + ]) + ) + )"#; + +const ROUTINE_MEMBER_QUERY: &str = r#" +[(routine_definition) @routinedef ? +(compiled_header) @routinedef ? +(statement (procedure)) @procedure ? +(dotted_statement (tag)) @dottedstatement ? +(statement (tag_statement)) @subroutine ?]"#; + +fn cached_query( + query: &'static OnceLock, + language: TsLanguage, + source: &str, + name: &str, +) -> &'static Query { + query.get_or_init(|| { + Query::new(&language, source) + .unwrap_or_else(|error| panic!("failed to compile {name} Tree-sitter query: {error}")) + }) +} + +fn udl_class_header_query() -> &'static Query { + static QUERY: OnceLock = OnceLock::new(); + cached_query( + &QUERY, + LANGUAGE_OBJECTSCRIPT_UDL.into(), + UDL_CLASS_HEADER_QUERY, + "UDL class header", + ) +} + +fn udl_class_member_query() -> &'static Query { + static QUERY: OnceLock = OnceLock::new(); + cached_query( + &QUERY, + LANGUAGE_OBJECTSCRIPT_UDL.into(), + UDL_CLASS_MEMBER_QUERY, + "UDL class member", + ) } + +fn routine_member_query() -> &'static Query { + static QUERY: OnceLock = OnceLock::new(); + cached_query( + &QUERY, + LANGUAGE_OBJECTSCRIPT_ROUTINE.into(), + ROUTINE_MEMBER_QUERY, + "routine member", + ) +} + impl Class { /// Creates a new `Class` with the given name and empty semantic state. /// @@ -25,19 +93,30 @@ impl Class { name, imports: Vec::new(), inherited_classes: Vec::new(), - inheritance_direction: "left".to_string(), + inheritance_direction: None, is_procedure_block: None, default_language: None, methods: HashMap::new(), - private_properties: HashMap::new(), - public_properties: HashMap::new(), + properties: HashMap::new(), parameters: HashMap::new(), active: true, is_rtn, next_method_id: 0, + next_parameter_id: 0, + next_property_id: 0, + is_final: None, } } + pub fn reset_keywords(&mut self) { + self.active = true; + self.is_final = None; + self.inherited_classes = Vec::new(); + self.inheritance_direction = None; + self.is_procedure_block = None; + self.default_language = None; + } + /// 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 @@ -46,406 +125,640 @@ impl Class { self.name = class_name; self.imports = Vec::new(); self.inherited_classes = Vec::new(); - self.inheritance_direction = "left".to_string(); + self.inheritance_direction = None; self.is_procedure_block = None; self.default_language = None; self.methods = HashMap::new(); - self.private_properties = HashMap::new(); - self.public_properties = HashMap::new(); + self.properties = HashMap::new(); self.parameters = HashMap::new(); self.active = active; self.next_method_id = 0; + self.next_parameter_id = 0; + self.next_property_id = 0; + self.is_final = None; } /// Allocates and returns the next sequential method ID for this class. - pub fn next_id(&mut self) -> usize { + pub fn get_next_method_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; + /// Allocates and returns the next sequential parameter ID for this class. + pub fn get_next_parameter_id(&mut self) -> usize { + let id = self.next_parameter_id; + self.next_parameter_id += 1; + id } - /// 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()); + /// Allocates and returns the next sequential property ID for this class. + pub fn get_next_property_id(&mut self) -> usize { + let id = self.next_property_id; + self.next_property_id += 1; + id + } - // each child is a class statement - for class_statement in class_statements { - let Some(statement_type) = class_statement.named_child(0) else { + /// Given a tree, parse the children, and add any imports + pub fn build_imports(&mut self, tree: &Tree, content: &str) { + let source_file_children = get_node_children(tree.root_node()); + 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 statement node {:?} has no child at index 0", - class_statement.kind() + "Error: class name child should exist at index 0, must update parsing in get_imports_for_class" ); 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; - } + if let Some(name) = + get_string_at_byte_range(content, identifier.byte_range()) + { + self.imports.push(name); } } } - _ => { - 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( + /// Clear any stale class members from this class and rebuild the class keywords. + /// Returns HashSets of methods to remove, methods to add, and the new class keywords. + pub fn build_class( &mut self, - node: Node, + root_node: Node, content: &str, - methods: &mut Vec<(Method, Range, usize)>, + is_rtn: bool, + class_id: &ClassId, + class_range: Range, + class_name: &String, + ) -> ( + bool, // Whether inherited classes changed + HashSet, // stale methods + HashMap)>, // new methods + HashMap, // new properties + HashMap, // new parameters + Vec<(String, LspRange)>, // new inherited classes + HashMap)>, // all methods info + Vec, ) { - 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 + // (inheritance_changed, recompute_inheritance_keyword, class_name_changed, class_is_final, class_is_procedure_block, class_name) + // // stale methods, stale prop, stale param + // new methods, new prop, new param + self.reset_keywords(); + let mut inherited_count = 0; + let mut new_methods = HashMap::new(); + let mut new_properties = HashMap::new(); + let mut new_parameters = HashMap::new(); + let mut all_methods = HashMap::new(); + let mut diagnostics = Vec::new(); + let mut old_methods: HashSet = self.methods.keys().cloned().collect(); + let mut inheritance_changed = false; + let old_inheritance_direction = self.inheritance_direction.clone(); + let mut inherited_classes = Vec::new(); + // NOTE: right now, properties and parameters are not incremental.. they are so small in terms of what it takes to rebuild that it doesn't make sense to incrementally build them atm + self.properties.clear(); + self.parameters.clear(); + self.next_property_id = 0; + self.next_parameter_id = 0; + if !is_rtn { + let query = udl_class_header_query(); + let inherits_idx = query.capture_index_for_name("inherits"); + let keyword_idx = query.capture_index_for_name("classkeyword"); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(query, root_node, content.as_bytes()); + + while let Some(query_match) = iter.next() { + let mut i = 0; + while i < query_match.captures.len() { + let capture = &query_match.captures[i]; + if inherits_idx == Some(capture.index) { + if let Some(inherited_cls_name) = + get_string_at_byte_range(content, capture.node.byte_range()) + { + let lsp_range = ts_range_to_lsp_range(content, capture.node.range()); + inherited_classes.push((inherited_cls_name.clone(), lsp_range)); + // inherited_class_ranges.insert(inherited_cls_name.clone(), lsp_range); + if let Some((old_inherited_class, _)) = + self.inherited_classes.get(inherited_count) + { + if &inherited_cls_name != old_inherited_class { + inheritance_changed = true; + } + } else { + inheritance_changed = true; + } + } + inherited_count += 1; + } else if keyword_idx == Some(capture.index) + && let Some(keyword_str) = + get_string_at_byte_range(content, capture.node.byte_range()) + { + let (not, keyword_name, values) = + get_keyword_and_value(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" { + if let Some(value) = values.get(0).copied() { + if value == "objectscript" { + self.default_language = Some(Language::Objectscript); + } else if value == "tsql" { + self.default_language = Some(Language::TSql); + } + } + } else if keyword_name == "inheritance" { + if let Some(value) = values.get(0).copied() { + if value == "right" { + self.inheritance_direction = Some("right".to_string()); + } else { + self.inheritance_direction = Some("left".to_string()); } + if self.inheritance_direction != old_inheritance_direction { + inheritance_changed = true; + } + } + } else if keyword_name == "final" { + if not { + self.is_final = Some(false); + } else { + self.is_final = Some(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(); + i += 1; } - "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 query = if is_rtn { + routine_member_query() + } else { + udl_class_member_query() + }; + { + let mut capture_indices = HashMap::new(); + if let Some(method_idx) = query.capture_index_for_name("classmethod") { + capture_indices.insert(method_idx, MemberType::ClassMethodCall); + } + if let Some(routine_idx) = query.capture_index_for_name("routinedef") { + capture_indices.insert(routine_idx, MemberType::Routine); + } + if let Some(subroutine_idx) = query.capture_index_for_name("subroutine") { + capture_indices.insert(subroutine_idx, MemberType::RoutineMethodCall); + } + if let Some(procedure_idx) = query.capture_index_for_name("procedure") { + capture_indices.insert(procedure_idx, MemberType::Procedure); + } + if let Some(method_idx) = query.capture_index_for_name("method") { + capture_indices.insert(method_idx, MemberType::MethodDef); + } + if let Some(param_idx) = query.capture_index_for_name("parameter") { + capture_indices.insert(param_idx, MemberType::RelativeParameter); + } + if let Some(prop_idx) = query.capture_index_for_name("property") { + capture_indices.insert(prop_idx, MemberType::RelativeProperty); + } + if let Some(prop_idx) = query.capture_index_for_name("dottedstatement") { + capture_indices.insert(prop_idx, MemberType::DottedStatementTag); + } + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(query, root_node, content.as_bytes()); - 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" + while let Some(query_match) = iter.next() { + let mut i = 0; + while i < query_match.captures.len() { + let capture = &query_match.captures[i]; + if let Some(cap_type) = capture_indices.get(&capture.index) { + match cap_type { + MemberType::Procedure => { + let procedure_statement_node = capture.node; + if let Some(( + method_name, + method_name_range, + method_range, + method_type, + public_variables_declared, + )) = get_procedure_info(&procedure_statement_node, content) + { + let existed = old_methods.remove(&method_name); + if all_methods.contains_key(&method_name) { + let lsp_range = + ts_range_to_lsp_range(content, method_name_range); + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::ERROR), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "A Method named {:?} already exists in this class.", + &method_name + ), + related_information: None, + tags: None, + data: None, + }; + diagnostics.push(diagnostic); + } + if !existed { + { + let new_method_id = self.get_next_method_id(); + let method_ref = MethodRef { + id: MethodId(new_method_id), + class: *class_id, + offset: None, + }; + self.methods.insert(method_name.clone(), method_ref); + let method = Method::new( + method_name.clone(), + public_variables_declared.clone(), + method_type, + ); + new_methods.insert( + method_name.clone(), + ( + method, + method_range, + method_ref, + public_variables_declared.clone(), + ), + ); + } + } + all_methods.insert( + method_name, + (method_range, method_type, public_variables_declared), ); } - "keyword_private" => { - is_public = false; + i += 1; + continue; + } + MemberType::DottedStatementTag => { + let subroutine_statement_node = capture.node; + if let Some(( + method_name, + method_name_range, + method_range, + method_type, + )) = + get_dotted_subroutine_info(&subroutine_statement_node, content) + { + let existed = old_methods.remove(&method_name); + if all_methods.contains_key(&method_name) { + let lsp_range = + ts_range_to_lsp_range(content, method_name_range); + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::ERROR), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "A Method named {:?} already exists in this class.", + &method_name + ), + related_information: None, + tags: None, + data: None, + }; + diagnostics.push(diagnostic); + } + if !existed { + { + let new_method_id = self.get_next_method_id(); + let method_ref = MethodRef { + id: MethodId(new_method_id), + class: *class_id, + offset: None, + }; + self.methods.insert(method_name.clone(), method_ref); + let method = Method::new( + method_name.clone(), + HashSet::new(), + method_type, + ); + new_methods.insert( + method_name.clone(), + (method, method_range, method_ref, HashSet::new()), + ); + } + } + all_methods.insert( + method_name, + (method_range, method_type, HashSet::new()), + ); } - _ => {} + i += 1; + continue; } - } - // 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; + MemberType::RoutineMethodCall => { + let subroutine_statement_node = capture.node; + if let Some(( + method_name, + method_name_range, + method_range, + method_type, + )) = get_subroutine_info(&subroutine_statement_node, content) + { + let existed = old_methods.remove(&method_name); + if all_methods.contains_key(&method_name) { + let lsp_range = + ts_range_to_lsp_range(content, method_name_range); + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::ERROR), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "A Method named {:?} already exists in this class.", + &method_name + ), + related_information: None, + tags: None, + data: None, + }; + diagnostics.push(diagnostic); } + if !existed { + { + let new_method_id = self.get_next_method_id(); + let method_ref = MethodRef { + id: MethodId(new_method_id), + class: *class_id, + offset: None, + }; + self.methods.insert(method_name.clone(), method_ref); + let method = Method::new( + method_name.clone(), + HashSet::new(), + method_type, + ); + new_methods.insert( + method_name.clone(), + (method, method_range, method_ref, HashSet::new()), + ); + } + } + all_methods.insert( + method_name, + (method_range, method_type, HashSet::new()), + ); } + i += 1; + continue; } - 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; + MemberType::Routine => { + let routine_node = capture.node; + if class_name != &self.name { + self.name = class_name.clone(); + } + if let Some(method_range) = get_routine_method_range( + &routine_node, + class_range.end_point, + class_range.end_byte, + ) { + let existed = old_methods.remove(class_name); + if !existed { + { + let new_method_id = self.get_next_method_id(); + let method_ref = MethodRef { + id: MethodId(new_method_id), + class: *class_id, + offset: None, + }; + self.methods.insert(class_name.clone(), method_ref); + let method = Method::new( + class_name.clone(), + HashSet::new(), + MethodType::Routine, + ); + new_methods.insert( + class_name.clone(), + (method, method_range, method_ref, HashSet::new()), + ); + } + } + all_methods.insert( + class_name.clone(), + (method_range, MethodType::Routine, HashSet::new()), + ); + } + + i += 1; + continue; + } + MemberType::ClassMethodCall => { + let method_definition_capture = capture.node; + if let Some(method_name_outer) = + method_definition_capture.named_child(0) + && let Some(method_name_node) = method_name_outer.named_child(0) + && let Some(method_name) = get_string_at_byte_range( + content, + method_name_node.byte_range(), + ) + { + let existed = old_methods.remove(&method_name); + if all_methods.contains_key(&method_name) { + let lsp_range = ts_range_to_lsp_range( + content, + method_name_node.range(), + ); + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::ERROR), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "A Method named {:?} already exists in this class.", + &method_name + ), + related_information: None, + tags: None, + data: None, + }; + diagnostics.push(diagnostic); + } + if !existed { + { + let new_method_id = self.get_next_method_id(); + let method_ref = MethodRef { + id: MethodId(new_method_id), + class: *class_id, + offset: None, + }; + self.methods.insert(method_name.clone(), method_ref); + let method = Method::new( + method_name.clone(), + HashSet::new(), + MethodType::ClassMethod, + ); + new_methods.insert( + method_name.clone(), + ( + method, + method_definition_capture.range(), + method_ref, + HashSet::new(), + ), + ); + } + } + all_methods.insert( + method_name, + ( + method_definition_capture.range(), + MethodType::ClassMethod, + HashSet::new(), + ), + ); + } + i += 1; + continue; + } + MemberType::MethodDef => { + let method_definition_capture = capture.node; + if let Some(method_name_outer) = + method_definition_capture.named_child(0) + && let Some(method_name_node) = method_name_outer.named_child(0) + && let Some(method_name) = get_string_at_byte_range( + content, + method_name_node.byte_range(), + ) + { + let existed = old_methods.remove(&method_name); + if all_methods.contains_key(&method_name) { + let lsp_range = ts_range_to_lsp_range( + content, + method_name_node.range(), + ); + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::ERROR), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "A Method named {:?} already exists in this class.", + &method_name + ), + related_information: None, + tags: None, + data: None, }; - public_variables_declared.push(var_name) + diagnostics.push(diagnostic); } + if !existed { + { + let new_method_id = self.get_next_method_id(); + let method_ref = MethodRef { + id: MethodId(new_method_id), + class: *class_id, + offset: None, + }; + self.methods.insert(method_name.clone(), method_ref); + let method = Method::new( + method_name.clone(), + HashSet::new(), + MethodType::InstanceMethod, + ); + new_methods.insert( + method_name.clone(), + ( + method, + method_definition_capture.range(), + method_ref, + HashSet::new(), + ), + ); + } + } + all_methods.insert( + method_name, + ( + method_definition_capture.range(), + MethodType::InstanceMethod, + HashSet::new(), + ), + ); } - "keyword_public" => { - is_public = true; + i += 1; + continue; + } + MemberType::RelativeProperty => { + let property_node = capture.node; + if let Some(property_name) = + get_property_name(&property_node, content) + { + let new_property_id = self.get_next_property_id(); + let property_ref = PropertyRef { + id: PropertyId(new_property_id), + class: *class_id, + }; + self.properties.insert(property_name.clone(), property_ref); + let mut property = Property::new(property_name.clone()); + property.build_keywords(property_node, content, None, None); + new_properties.insert( + property_name.clone(), + (property, property_node.range(), property_ref), + ); } - _ => { - continue; + i += 1; + continue; + } + MemberType::RelativeParameter => { + let parameter_node = capture.node; + if let Some(parameter_name) = + get_parameter_name(¶meter_node, content) + { + let new_parameter_id = self.get_next_parameter_id(); + let parameter_ref = ParameterRef { + id: ParameterId(new_parameter_id), + class: *class_id, + }; + self.parameters + .insert(parameter_name.clone(), parameter_ref); + + let mut parameter = Parameter::new(parameter_name.clone()); + parameter.build_keywords(parameter_node, content, None, None); + new_parameters.insert( + parameter_name.clone(), + (parameter, parameter_node.range(), parameter_ref), + ); } + i += 1; + continue; + } + _ => { + i += 1; + 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(); + eprintln!("error: didn't match type, but node is {:?}", capture.node); + i += 1; 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 - } + self.inherited_classes = inherited_classes.clone(); - /// 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 - } - } + ( + inheritance_changed, + old_methods, + new_methods, + new_properties, + new_parameters, + inherited_classes, + all_methods, + diagnostics, + ) } /// Returns the `PublicMethodId` for `method_name`, if this class declares it as public. diff --git a/objectscript-lsp/crates/objectscript-core/src/common.rs b/objectscript-lsp/crates/objectscript-core/src/common.rs index 841635e..071256f 100644 --- a/objectscript-lsp/crates/objectscript-core/src/common.rs +++ b/objectscript-lsp/crates/objectscript-core/src/common.rs @@ -1,13 +1,17 @@ -use crate::parse_structures::{ClassId, MemberType, ReturnType}; +use crate::parse_structures::{ClassId, MemberType, MethodType, ReturnType}; use crate::refactor::count_leading_dots_in_line; use crate::scope_structures::ScopeId; use crate::scope_tree::ScopeTree; use regex::Regex; +use std::collections::HashSet; use std::ops::Range as CoreRange; +use std::sync::OnceLock; use tower_lsp::lsp_types::{Position, Range as LspRange, Url}; use tree_sitter::{ - Node, Point, Query, QueryCursor, Range as TsRange, Range, StreamingIterator, Tree, TreeCursor, + Language as TsLanguage, Node, Point, Query, QueryCursor, Range as TsRange, Range, + StreamingIterator, Tree, TreeCursor, }; +use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; use tree_sitter_xml::LANGUAGE_XML; @@ -32,6 +36,40 @@ const XML_OBJECTSCRIPT_INJECTIONS_QUERY: &str = r#" ) "#; +const CLASS_NAME_QUERY: &str = "(class_definition (class_name (identifier) @classname))"; + +fn cached_query( + query: &'static OnceLock, + language: TsLanguage, + source: &str, + name: &str, +) -> &'static Query { + query.get_or_init(|| { + Query::new(&language, source) + .unwrap_or_else(|error| panic!("failed to compile {name} Tree-sitter query: {error}")) + }) +} + +fn class_name_query() -> &'static Query { + static QUERY: OnceLock = OnceLock::new(); + cached_query( + &QUERY, + LANGUAGE_OBJECTSCRIPT_UDL.into(), + CLASS_NAME_QUERY, + "class name", + ) +} + +fn xml_objectscript_injections_query() -> &'static Query { + static QUERY: OnceLock = OnceLock::new(); + cached_query( + &QUERY, + LANGUAGE_XML.into(), + XML_OBJECTSCRIPT_INJECTIONS_QUERY, + "XML ObjectScript injections", + ) +} + /// Logs override resolution results for a method/superclass pair for debugging. pub fn print_statements_exit_method_overrides_fn( method_name: &str, @@ -320,7 +358,11 @@ pub fn find_class_definition(root: Node) -> Option { } /// 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 { +pub fn get_member_name_and_range_from_root( + content: &str, + node: Node, + is_rtn: bool, +) -> Option<(Range, String, Range)> { return if is_rtn { get_routine_name_from_root(content, node) } else { @@ -335,77 +377,29 @@ pub fn get_member_name_from_root(content: &str, node: Node, is_rtn: bool) -> Opt /// /// 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") +fn get_class_name_from_root(content: &str, node: Node) -> Option<(Range, String, Range)> { + let query = class_name_query(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(query, node, content.as_bytes()); + while let Some(query_match) = iter.next() { + let matched_node = query_match.captures[0].node; // this is the identifier node + let mut parent_node = matched_node.parent(); + while let Some(parent) = parent_node { + if parent.kind() == "class_definition" { + if let Some(class_name) = + get_string_at_byte_range(content, matched_node.byte_range()) { - return Some(Range { - start_byte, - end_byte, - start_point: start_position, - end_point: end_position, - }); + return Some((parent.range(), class_name, matched_node.range())); } } - _ => return None, + parent_node = parent.parent(); } } None } /// Given root node (source_file), find the routine name -fn get_routine_name_from_root(content: &str, root: Node) -> Option { +fn get_routine_name_from_root(content: &str, root: Node) -> Option<(Range, String, Range)> { // either it starts as a statement or as a routine_def if let Some(node) = root.named_child(0) { match node.kind() { @@ -422,7 +416,12 @@ fn get_routine_name_from_root(content: &str, root: Node) -> Option { ); return None; } - return get_string_at_byte_range(content, name_node.byte_range()); + + if let Some(routine_name) = + get_string_at_byte_range(content, name_node.byte_range()) + { + return Some((root.range(), routine_name, name_node.range())); + } } "statement" | "compiled_header" => { let routine_children = get_node_children(root); @@ -445,7 +444,11 @@ fn get_routine_name_from_root(content: &str, root: Node) -> Option { eprintln!("Error: Expected tag_statement to have child at index 0"); continue; }; - return get_string_at_byte_range(content, tag.byte_range()); + if let Some(routine_name) = + get_string_at_byte_range(content, tag.byte_range()) + { + return Some((root.range(), routine_name, tag.range())); + } } } } @@ -471,17 +474,28 @@ pub fn get_string_at_byte_range(content: &str, range: CoreRange) -> Optio /// 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, + "%exactstring" | "%enumstring" | "%string" | "%char" | "text" | "string" => { + ReturnType::String + } + "%bigint" | "%smallint" | "%integer" | "%posixtime" | "%counter" | "integer" => { + ReturnType::Integer + } "%tinyint" => ReturnType::TinyInteger, "%binary" => ReturnType::Binary, "%date" => ReturnType::Date, "%double" => ReturnType::Double, "%numeric" | "%time" => ReturnType::Number, "%status" => ReturnType::Status, - "%sqlquery" => ReturnType::SqlQuery, + "%sqlquery" | "sql" => ReturnType::SqlQuery, + "boolean" => ReturnType::Boolean, + "classname" => ReturnType::ClassName, + "coscode" => ReturnType::CosCode, + "cosidentifier" => ReturnType::Variable, + "cosexpression" => ReturnType::Expression, + "sqlidentifier" => ReturnType::SqlIdentifier, + "configvalue" => ReturnType::ConfigValue, _ => { - eprintln!("Unimplemented return type: {:?}", typename); + // eprintln!("Unimplemented return type: {:?}", typename); ReturnType::Other(typename) } }; @@ -597,7 +611,7 @@ pub fn get_keyword_and_value(keyword: &str) -> (bool, String, Vec<&str>) { /// 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, + tree: &Tree, class_symbol_id: ClassId, content: &str, is_rtn: bool, @@ -622,10 +636,12 @@ fn build_scope_skeleton( ) { let is_scope; let method_name; + let in_routine_statements; if !is_rtn { (is_scope, method_name) = cls_is_scope_node(node, content); + in_routine_statements = false; } else { - (is_scope, method_name) = rtn_is_scope_node(node, content); + (is_scope, method_name, in_routine_statements) = rtn_is_scope_node(node, content); } if is_scope { let scope_start; @@ -645,7 +661,13 @@ fn build_scope_skeleton( ); return; }; - let scope_id = scope_tree.add_scope(scope_start, scope_end, parent, method_name); + let scope_id = scope_tree.add_scope( + scope_start, + scope_end, + parent, + method_name, + in_routine_statements, + ); scope_stack.push(scope_id); } @@ -668,6 +690,11 @@ pub fn point_in_range(pos: Point, start: Point, end: Point) -> bool { false } +pub fn range_within_range(inner: &Range, outer: &Range) -> bool { + // Tree-sitter end points are exclusive, so equality at the end still means contained. + inner.start_point >= outer.start_point && inner.end_point <= outer.end_point +} + /// 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) { @@ -690,7 +717,9 @@ pub fn cls_is_scope_node(node: Node, content: &str) -> (bool, Option) { | "else_block_dotted" | "elseif_block_dotted" | "command_if_dotted_block" - | "command_else" => is_scope = true, + | "command_else" => { + is_scope = true; + } _ => is_scope = false, } (is_scope, method_name_str) @@ -726,8 +755,232 @@ fn get_scope_range(node: Node) -> Range { } } +/// Determines if a node kind terminates a routine method scope. +pub 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"; + } +} + +/// Given a statement node of a procedure statement, get the range of the method it defines +pub fn get_procedure_info( + node: &Node, + content: &str, +) -> Option<(String, Range, Range, MethodType, HashSet)> { + let Some(statement_type) = node.named_child(0) else { + eprintln!("Error: Expected Statement node to have child at index 0"); + return None; + }; + let Some(tag) = statement_type.named_child(0) else { + eprintln!( + "Expected procedure node to have a child at index 0, aborting initial_build_procedure" + ); + return None; + }; + let Some(method_name) = get_string_at_byte_range(content, tag.byte_range()) else { + return None; + }; + let method_name_range = tag.range(); + let procedure_range = statement_type.range(); + let mut is_public = false; + let procedure_children = get_node_children(statement_type); + let mut public_variables_declared = HashSet::new(); + for procedure_statement in procedure_children { + match procedure_statement.kind() { + "keyword_public" => { + is_public = true; + } + "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.insert(var_name); + } + } + _ => { + continue; + } + } + } + return Some(( + method_name, + method_name_range, + procedure_range, + MethodType::Procedure(is_public), + public_variables_declared, + )); +} + +/// Given a routine_definition node or compiled_header node, get the range of the statements that come before the first tag statement or procedure or quit. +pub fn get_routine_method_range(node: &Node, end_point: Point, end_byte: usize) -> Option { + let mut saw_first_tag_statement = false; + let is_compiled_header = node.kind() == "compiled_header"; + // get statement siblings until one is tag_statement or procedure + let mut next_sibling = node.next_named_sibling(); + let routine_start_point = node.start_position(); + let routine_start_byte = node.start_byte(); + let mut routine_scope_end_point = end_point; + let mut routine_scope_end_byte = 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, + }; + return Some(routine_range); +} + +/// Given a property node, get the name +pub fn get_property_name(node: &Node, content: &str) -> Option { + let Some(property_name_node_outer) = node.named_child(1) else { + eprintln!( + "Error: expected property node {:?} to have child at node 1", + node.kind() + ); + return None; + }; + let Some(property_name_node) = property_name_node_outer.named_child(0) else { + eprintln!("Error: expected property name node to have child at node 0"); + return None; + }; + + get_string_at_byte_range(content, property_name_node.byte_range()) +} + +/// Given a parameter node, get the name +pub fn get_parameter_name(node: &Node, content: &str) -> Option { + let Some(parameter_name_node_outer) = node.named_child(1) else { + eprintln!("Error: expected parameter node to have child at node 1"); + return None; + }; + let Some(parameter_name_node) = parameter_name_node_outer.named_child(0) else { + eprintln!("Error: expected parameter name node to have child at node 0"); + return None; + }; + + get_string_at_byte_range(content, parameter_name_node.byte_range()) +} + +/// Given the dotted statement line, find the tag +pub fn get_dotted_subroutine_info( + node: &Node, + content: &str, +) -> Option<(String, Range, Range, MethodType)> { + if let Some(dotted_statement_parent) = node.parent() + && let Some(tag_node) = node.named_child(0) + && let Some(method_name) = get_string_at_byte_range(content, tag_node.byte_range()) + { + if dotted_statement_parent.kind() != "command_do" { + eprintln!( + "Error: Expected dotted statement node to have parent of kind command_do, but got {:?}", + dotted_statement_parent.kind() + ); + return None; + } + return Some(( + method_name, + tag_node.range(), + dotted_statement_parent.range(), + MethodType::DottedSubroutine(true), + )); + } + eprintln!("Error: Expected dotted statement node to have a parent, but it didn't"); + + None +} + +/// Given a statement node of a tag statement, get the range of the method it defines +pub fn get_subroutine_info( + node: &Node, + content: &str, +) -> Option<(String, Range, Range, MethodType)> { + let mut is_public = true; + let Some(statement_type) = node.named_child(0) else { + eprintln!("Error: Expected Statement node to have child at index 0"); + // curr_routine_child = routine_child.next_named_sibling(); + return None; + }; + if statement_type.kind() != "tag_statement" { + return None; + } + let Some(tag) = statement_type.named_child(0) else { + eprintln!("Error: expected tag statement node to have child at node 0"); + return None; + }; + + let Some(method_name) = get_string_at_byte_range(content, tag.byte_range()) else { + return None; + }; + + if let Some(tag_keyword) = + statement_type.named_child((statement_type.named_child_count() - 1) as u32) + { + match tag_keyword.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 = node.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, + }; + return Some(( + method_name, + tag.range(), + subroutine_range, + MethodType::Subroutine(is_public), + )); +} + /// 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) { +pub fn rtn_is_scope_node(node: Node, content: &str) -> (bool, Option, bool) { let mut method_name_str = None; let mut is_scope = false; match node.kind() { @@ -737,25 +990,47 @@ pub fn rtn_is_scope_node(node: Node, content: &str) -> (bool, Option) { | "else_block_dotted" | "elseif_block_dotted" | "command_if_dotted_block" - | "command_else" => return (true, None), + | "command_else" => return (true, None, 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)" + "Sibling node {:?} for tag statement {:?} did not have a child at index 0, skipping (rtn_is_scope_node)", + sibling, node ); sib = sibling.prev_named_sibling(); continue; }; if command.kind() == "tag_statement" { - return (false, None); + sib = sibling.prev_named_sibling(); + while let Some(last_sib) = sib { + if last_sib.kind() == "routine_definition" + || last_sib.kind() == "compiled_header" + { + return (true, None, true); + } + let Some(command) = last_sib.named_child(0) else { + eprintln!( + "Sibling node {:?} for tag statement {:?} did not have a child at index 0, skipping (rtn_is_scope_node)", + last_sib, node + ); + sib = last_sib.prev_named_sibling(); + continue; + }; + if command.kind() == "procedure" || command.kind() == "command_quit" { + return (true, None, false); + } + sib = last_sib.prev_named_sibling(); + } + // No quit or procedure found, this is a routine statement + return (true, None, true); } 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); + return (true, method_name_str, false); } sib = sibling.prev_named_sibling(); } @@ -763,7 +1038,7 @@ pub fn rtn_is_scope_node(node: Node, content: &str) -> (bool, Option) { 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); + return (true, method_name_str, true); } if node.kind() == "procedure" { if let Some(tag) = node.named_child(0) { @@ -777,13 +1052,13 @@ pub fn rtn_is_scope_node(node: Node, content: &str) -> (bool, Option) { let Some(dotted_statement_line) = get_string_at_byte_range(content, node.byte_range()) else { - return (is_scope, method_name_str); + return (is_scope, method_name_str, false); }; 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); + return (is_scope, method_name_str, false); }; let sib_depth = count_leading_dots_in_line(&sib_dotted_statement_line); if depth > sib_depth { @@ -813,7 +1088,8 @@ pub fn rtn_is_scope_node(node: Node, content: &str) -> (bool, Option) { 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)" + "Sibling node {:?} did not have a child at index 0, skipping (rtn_is_scope_node)", + sibling.kind() ); curr_sib = sibling.prev_named_sibling(); continue; @@ -834,7 +1110,8 @@ pub fn rtn_is_scope_node(node: Node, content: &str) -> (bool, Option) { } } } - (is_scope, method_name_str) + + (is_scope, method_name_str, false) } } } @@ -906,7 +1183,27 @@ pub fn get_routine_scope_node_range(node: Node, content: &str) -> (Point, Point) /// [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), + "parameter_name" => { + let Some(parameter_name_parent) = node.parent() else { + eprintln!("Error: expected method_name node to have parent"); + return None; + }; + return match parameter_name_parent.kind() { + "oref_parameter" => { + let Some(oref_parameter_parent) = parameter_name_parent.parent() else { + eprintln!("Error: expected oref_parameter node to have parent"); + return None; + }; + if oref_parameter_parent.kind() == "relative_dot_parameter" { + Some(MemberType::RelativeParameter) + } else { + Some(MemberType::OrefParameter) + } + } + "parameter" => Some(MemberType::ParameterDef), + _ => Some(MemberType::RelativeParameter), + }; + } "projection_name" => Some(MemberType::Projection), "class_name" => { let Some(class_name_parent) = node.parent() else { @@ -921,7 +1218,27 @@ pub fn get_outer_type_from_identifier(node: &Node) -> Option { } "query_name" => Some(MemberType::Query), "trigger_name" => Some(MemberType::Trigger), - "property_name" => Some(MemberType::Property), + "property_name" => { + let Some(property_name_parent) = node.parent() else { + eprintln!("Error: expected method_name node to have parent"); + return None; + }; + return match property_name_parent.kind() { + "oref_property" => { + let Some(oref_property_parent) = property_name_parent.parent() else { + eprintln!("Error: expected oref_property node to have parent"); + return None; + }; + if oref_property_parent.kind() == "relative_dot_property" { + Some(MemberType::RelativeProperty) + } else { + Some(MemberType::OrefProperty) + } + } + "property" => Some(MemberType::PropertyDef), + _ => Some(MemberType::RelativeProperty), + }; + } "relationship_name" => Some(MemberType::Relationship), "foreignkey_name" => Some(MemberType::Foreignkey), "index_name" => Some(MemberType::Index), @@ -999,6 +1316,13 @@ pub fn parse_line_ref( // this should be the subroutine name method_name = get_string_at_byte_range(content, line_ref_child.byte_range()); } + "method_name" => { + if let Some(identifier) = line_ref_child.named_child(0) { + method_name = get_string_at_byte_range(content, identifier.byte_range()); + } else { + 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) @@ -1088,14 +1412,11 @@ pub fn collect_error_nodes<'tree>(root: Node<'tree>) -> Vec> { /// 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 query = xml_objectscript_injections_query(); 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()); + let mut matches = cursor.matches(query, root, content.as_bytes()); while let Some(query_match) = matches.next() { for capture in query_match.captures { diff --git a/objectscript-lsp/crates/objectscript-core/src/config.rs b/objectscript-lsp/crates/objectscript-core/src/config.rs index a05fe31..db05924 100644 --- a/objectscript-lsp/crates/objectscript-core/src/config.rs +++ b/objectscript-lsp/crates/objectscript-core/src/config.rs @@ -1,22 +1,32 @@ use serde::Deserialize; +use serde_json::{Map, Value}; /// 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)] +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] pub struct Config { /// Enables completion items that include snippet text edits. + #[serde(alias = "enable_snippets")] pub enable_snippets: bool, /// Enables document formatting support. + #[serde(alias = "enable_formatting")] pub enable_formatting: bool, /// Enables lint/diagnostic checks. - enable_lint: bool, + #[serde(alias = "enable_lint")] + pub enable_lint: bool, /// Enables stricter parsing/diagnostic behavior when supported. - enable_strict_mode: bool, + #[serde( + alias = "STRICT_MODE", + alias = "strictMode", + alias = "enable_strict_mode", + alias = "strict_mode" + )] + pub enable_strict_mode: bool, } impl Default for Config { @@ -30,3 +40,220 @@ impl Default for Config { } } } + +impl Config { + /// Parse config from an LSP initializationOptions or didChangeConfiguration payload. + /// + /// The server accepts the direct ObjectScript config object, common client wrapper shapes, + /// and flat VS Code-style keys: + /// + /// ```json + /// { "enableStrictMode": true } + /// { "objectscript": { "enableStrictMode": true } } + /// { "objectscript.enableStrictMode": true } + /// ``` + pub fn from_lsp_value(value: Value) -> Result { + serde_json::from_value(normalize_lsp_config_value(value)) + } + + /// Parse config only when the LSP payload actually contains ObjectScript config keys. + pub fn from_lsp_value_if_present(value: Value) -> Result, serde_json::Error> { + let normalized = normalize_lsp_config_value(value); + if contains_config_key(&normalized) { + serde_json::from_value(normalized).map(Some) + } else { + Ok(None) + } + } +} + +fn normalize_lsp_config_value(value: Value) -> Value { + let Value::Object(mut map) = value else { + return Value::Object(Map::new()); + }; + + for key in ["objectscript", "objectscriptLsp", "objectscript-lsp"] { + if let Some(nested) = map.remove(key) { + return nested; + } + } + + for key in ["initialization_options", "initializationOptions"] { + if let Some(initialization_options) = map.remove(key) { + return normalize_lsp_config_value(initialization_options); + } + } + + if let Some(settings) = map.remove("settings") { + return normalize_lsp_config_value(settings); + } + + if let Some(lsp) = map.remove("lsp").and_then(|value| match value { + Value::Object(lsp) => Some(lsp), + _ => None, + }) { + for key in ["objectscript-lsp", "objectscript_lsp", "objectscript"] { + if let Some(server_config) = lsp.get(key) { + if let Some(settings) = server_config.get("settings") { + return settings.clone(); + } + if let Some(initialization_options) = server_config.get("initialization_options") { + return initialization_options.clone(); + } + if let Some(initialization_options) = server_config.get("initializationOptions") { + return initialization_options.clone(); + } + return server_config.clone(); + } + } + } + + let mut dotted = Map::new(); + for (key, value) in &map { + if let Some(config_key) = key.strip_prefix("objectscript.") { + dotted.insert(config_key.to_string(), value.clone()); + } + } + + if dotted.is_empty() { + Value::Object(map) + } else { + Value::Object(dotted) + } +} + +fn contains_config_key(value: &Value) -> bool { + let Some(map) = value.as_object() else { + return false; + }; + + const CONFIG_KEYS: [&str; 13] = [ + "enableSnippets", + "enable_snippets", + "enableFormatting", + "enable_formatting", + "enableLint", + "enable_lint", + "enableStrictMode", + "enable_strict_mode", + "strictMode", + "strict_mode", + "STRICT_MODE", + "objectscript.enableStrictMode", + "objectscript.enable_strict_mode", + ]; + + map.keys() + .any(|key| CONFIG_KEYS.contains(&key.as_str()) || key.starts_with("objectscript.")) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_direct_camel_case_config() { + let config = Config::from_lsp_value(json!({ + "enableStrictMode": false, + "enableLint": false + })) + .expect("direct config should parse"); + + assert!(!config.enable_strict_mode); + assert!(!config.enable_lint); + assert!(config.enable_formatting); + assert!(config.enable_snippets); + } + + #[test] + fn parses_nested_objectscript_config() { + let config = Config::from_lsp_value(json!({ + "objectscript": { + "enableStrictMode": false + } + })) + .expect("nested config should parse"); + + assert!(!config.enable_strict_mode); + } + + #[test] + fn parses_flat_dotted_config() { + let config = Config::from_lsp_value(json!({ + "objectscript.enableStrictMode": false + })) + .expect("flat dotted config should parse"); + + assert!(!config.enable_strict_mode); + } + + #[test] + fn parses_legacy_strict_mode_aliases() { + let upper = Config::from_lsp_value(json!({ + "STRICT_MODE": false + })) + .expect("upper-case alias should parse"); + let legacy = Config::from_lsp_value(json!({ + "strictMode": false + })) + .expect("legacy alias should parse"); + + assert!(!upper.enable_strict_mode); + assert!(!legacy.enable_strict_mode); + } + + #[test] + fn parses_zed_lsp_settings_shape() { + let config = Config::from_lsp_value(json!({ + "lsp": { + "objectscript-lsp": { + "initialization_options": { + "enableStrictMode": false + } + } + } + })) + .expect("zed lsp settings should parse"); + + assert!(!config.enable_strict_mode); + } + + #[test] + fn parses_direct_initialization_options_wrapper() { + let config = Config::from_lsp_value(json!({ + "initialization_options": { + "enableStrictMode": false + } + })) + .expect("direct initialization_options wrapper should parse"); + + assert!(!config.enable_strict_mode); + } + + #[test] + fn parses_did_change_settings_wrapper() { + let config = Config::from_lsp_value(json!({ + "settings": { + "objectscript": { + "enableStrictMode": false + } + } + })) + .expect("settings wrapper should parse"); + + assert!(!config.enable_strict_mode); + } + + #[test] + fn missing_config_keys_are_not_treated_as_config_updates() { + let config = Config::from_lsp_value_if_present(json!({ + "binary": { + "path": "/tmp/objectscript-lsp" + } + })) + .expect("unknown lsp settings should be ignored"); + + assert_eq!(config, None); + } +} diff --git a/objectscript-lsp/crates/objectscript-core/src/dependency_tracker.rs b/objectscript-lsp/crates/objectscript-core/src/dependency_tracker.rs index a390851..e89309f 100644 --- a/objectscript-lsp/crates/objectscript-core/src/dependency_tracker.rs +++ b/objectscript-lsp/crates/objectscript-core/src/dependency_tracker.rs @@ -1,15 +1,16 @@ use crate::parse_structures::ClassId; use crate::parse_structures::MethodRef; use petgraph::Direction; -use petgraph::graph::{DiGraph, NodeIndex}; +use petgraph::graph::{DiGraph, EdgeIndex, NodeIndex}; use petgraph::visit::EdgeRef; use std::collections::{HashMap, HashSet, VecDeque}; +use tower_lsp::lsp_types::Range as LspRange; use tree_sitter::Range; - /// Stores all subclasses that depend on a given class through inheritance. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug)] pub struct Dependents { - pub dependent_classes: HashMap>, + pub dependent_classes: HashMap>, + pub direct_subclasses: HashMap>, } impl Dependents { @@ -17,7 +18,37 @@ impl Dependents { pub fn new() -> Self { Self { dependent_classes: HashMap::new(), + direct_subclasses: HashMap::new(), + } + } + + pub fn get_direct_subclasses(&self, class_id: &ClassId) -> Option<&HashMap> { + self.direct_subclasses.get(class_id) + } + + pub fn get_transitive_subclasses(&self, class_id: &ClassId) -> Option<&HashSet> { + self.dependent_classes.get(class_id) + } + + // NOTE: direct subclasses must be up to date for this to work correctly. + pub fn rebuild_transitive_subclasses(&mut self, class_id: ClassId) { + let mut result = HashSet::new(); + let mut queue: VecDeque = VecDeque::new(); + + if let Some(direct) = self.direct_subclasses.get(&class_id) { + queue.extend(direct.keys().copied()); + } + + while let Some(child) = queue.pop_front() { + if !result.insert(child) { + continue; + } + if let Some(grandchildren) = self.direct_subclasses.get(&child) { + queue.extend(grandchildren.keys().copied()); + } } + + self.dependent_classes.insert(class_id, result); } } @@ -26,6 +57,7 @@ impl Dependents { pub struct DependencyGraph { pub graph: DiGraph, pub lookup: HashMap, + pub class_nodes: HashMap>, } impl DependencyGraph { @@ -34,6 +66,16 @@ impl DependencyGraph { Self { graph: DiGraph::new(), lookup: HashMap::new(), + class_nodes: HashMap::new(), + } + } + + /// Returns the graph NodeIndexes for all methods from a given Class of `class_id`, if it exists. + pub fn get_class_nodes(&self, class: &ClassId) -> Vec { + if let Some(indices) = self.class_nodes.get(class) { + return indices.clone(); + } else { + return Vec::new(); } } @@ -50,11 +92,98 @@ impl DependencyGraph { let idx = self.graph.add_node(method); self.lookup.insert(method, idx); + self.class_nodes + .entry(method.class) + .or_insert(Vec::new()) + .push(idx); idx } + pub fn get_method_ref_from_node_index(&self, node_index: NodeIndex) -> Option<&MethodRef> { + self.graph.node_weight(node_index) + } + + pub fn remove_edge(&mut self, target: EdgeIndex) { + self.graph.remove_edge(target); + } + + /// Removes and Returns all direct callers of a method IF the caller is from a different class by checking the direct edges to the node at `NodeIndex`. + pub fn remove_direct_ancestors( + &mut self, + target: NodeIndex, + classes_with_private_access: &HashSet, + ) -> HashSet<(MethodRef, Range)> { + // e.source is the caller (if it has the same classid, it won't be removed) + let incoming: Vec<(EdgeIndex, MethodRef, Range)> = self + .graph + .edges_directed(target, Direction::Incoming) + .filter(|e| !classes_with_private_access.contains(&self.graph[e.source()].class)) + .map(|e| (e.id(), self.graph[e.source()], *e.weight())) + .collect(); + let mut method_caller_refs = HashSet::new(); + for (edge_id, method_caller_ref, edge_weight) in incoming { + self.remove_edge(edge_id); + method_caller_refs.insert((method_caller_ref, edge_weight)); + } + method_caller_refs + } + + pub fn remove_incoming_calls_to_node( + &mut self, + target: NodeIndex, + ) -> HashSet<(MethodRef, Range)> { + // e.source is the caller (if it has the same classid, it won't be removed) + let incoming: Vec<(EdgeIndex, MethodRef, Range)> = self + .graph + .edges_directed(target, Direction::Incoming) + .map(|e| (e.id(), self.graph[e.source()], *e.weight())) + .collect(); + let mut method_caller_refs = HashSet::new(); + for (edge_id, method_caller_ref, edge_weight) in incoming { + self.remove_edge(edge_id); + method_caller_refs.insert((method_caller_ref, edge_weight)); + } + method_caller_refs + } + + /// Removes node representing `method_ref` from the graph. + /// DiGraph::remove_node swaps the last node into the removed slot, + /// so this updates `lookup` and `class_nodes` maps + pub fn remove_node(&mut self, method_ref: MethodRef) { + let Some(idx) = self.lookup.remove(&method_ref) else { + return; + }; + + // What node will be swapped into this index? + let last_idx = NodeIndex::new(self.graph.node_count() - 1); + let last_method_ref = if last_idx != idx { + Some(self.graph[last_idx]) + } else { + None + }; + + // Remove the node (removes all its edges too) + self.graph.remove_node(idx); + + // If a different node was swapped in, update its lookup entry + if let Some(swapped) = last_method_ref { + self.lookup.insert(swapped, idx); + // Update class_nodes for the swapped method + if let Some(nodes) = self.class_nodes.get_mut(&swapped.class) { + if let Some(pos) = nodes.iter().position(|&n| n == last_idx) { + nodes[pos] = idx; + } + } + } + + // Remove from class_nodes + if let Some(nodes) = self.class_nodes.get_mut(&method_ref.class) { + nodes.retain(|&n| n != idx); + } + } + /// Returns all transitive callers of a method via BFS (closest ancestors first). - /// Each entry includes the ancestor's MethodRef, the edge Range, and the BFS depth. + /// Each entry includes the ancestor's MethodRef, the edge Range (the method call range), and the BFS depth. pub fn all_ancestors(&self, target: NodeIndex) -> Vec<(MethodRef, Range, usize)> { let mut ancestors = Vec::new(); let mut visited = HashSet::new(); @@ -74,6 +203,71 @@ impl DependencyGraph { ancestors } + pub fn is_ancestor(&self, ancestor: MethodRef, descendant: MethodRef) -> bool { + let Some(&ancestor_idx) = self.get_node(ancestor) else { + return false; + }; + let Some(&descendant_idx) = self.get_node(descendant) else { + return false; + }; + + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + queue.push_back(descendant_idx); + visited.insert(descendant_idx); + + while let Some(node) = queue.pop_front() { + for edge in self.graph.edges_directed(node, Direction::Incoming) { + let parent = edge.source(); + if parent == ancestor_idx { + return true; + } + if visited.insert(parent) { + queue.push_back(parent); + } + } + } + false + } + + /// BFS upward from `target`, stopping at nodes where `is_definer` returns true. + /// Returns the closest definer on each distinct path — i.e. no definer in the result + /// is an ancestor of another. + /// Each result includes the MethodRef of the definer and the call-edge Range that led toward it. + pub fn closest_definers( + &self, + target: NodeIndex, + is_definer: impl Fn(&MethodRef, &Range) -> bool, + ) -> Vec<(MethodRef, Range)> { + let mut results = Vec::new(); + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + visited.insert(target); + + for edge in self.graph.edges_directed(target, Direction::Incoming) { + let parent = edge.source(); + if visited.insert(parent) { + queue.push_back((parent, *edge.weight())); + } + } + + while let Some((node, call_range)) = queue.pop_front() { + let method_ref = self.graph[node]; + if is_definer(&method_ref, &call_range) { + results.push((method_ref, call_range)); + } else { + for edge in self.graph.edges_directed(node, Direction::Incoming) { + let parent = edge.source(); + if visited.insert(parent) { + queue.push_back((parent, *edge.weight())); + } + } + } + } + + results + } + /// Adds a caller->callee edge, creating nodes if needed. pub fn add_edge(&mut self, caller: MethodRef, callee: MethodRef, method_call_range: Range) { let caller_idx = self.get_or_add_node(caller); diff --git a/objectscript-lsp/crates/objectscript-core/src/document.rs b/objectscript-lsp/crates/objectscript-core/src/document.rs index 9860f61..0610a5a 100644 --- a/objectscript-lsp/crates/objectscript-core/src/document.rs +++ b/objectscript-lsp/crates/objectscript-core/src/document.rs @@ -20,7 +20,7 @@ pub struct Document { /// 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, + pub class_name: String, } impl Document { @@ -33,7 +33,8 @@ impl Document { content: String, tree: Tree, file_type: FileType, - class_name: Option, + class_name: String, + class_id: Option, scope_tree: ScopeTree, version: Option, ) -> Self { @@ -43,7 +44,7 @@ impl Document { version, file_type, scope_tree, - class_id: None, + class_id: class_id, class_name, } } diff --git a/objectscript-lsp/crates/objectscript-core/src/global_semantic.rs b/objectscript-lsp/crates/objectscript-core/src/global_semantic.rs index 9101f75..262e423 100644 --- a/objectscript-lsp/crates/objectscript-core/src/global_semantic.rs +++ b/objectscript-lsp/crates/objectscript-core/src/global_semantic.rs @@ -1,12 +1,13 @@ 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, + Class, ClassId, DfsState, Language, Method, MethodRef, Parameter, ParameterRef, Property, + PropertyRef, PublicVarId, Variable, VariableRef, }; use crate::scope_structures::{ - ClassGlobalSymbol, MethodGlobalSymbol, ScopeId, VariableGlobalSymbol, + ClassGlobalSymbol, MethodSymbol, ParameterSymbol, PropertySymbol, ScopeId, + VariableGlobalSymbol, VariableSymbol, }; use std::collections::{HashMap, HashSet}; use tower_lsp::lsp_types::Url; @@ -19,14 +20,22 @@ pub struct GlobalSemanticModel { pub variables: HashMap>>, /// Stores all classes in a workspace. pub classes: HashMap, - /// Stores public methods per class. + /// Stores methods per class. pub methods: HashMap, + /// Stores properties in a workspace for public properties. + pub properties: HashMap, + /// Stores Parameters in a workspace for public parameters. + pub parameters: 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 Method Symbols in a workspace for public methods. + pub method_defs: HashMap, + /// Stores Property Symbols in a workspace for public properties. + pub property_defs: HashMap, + /// Stores Parameter Symbols in a workspace for public properties. + pub parameter_defs: HashMap, /// Stores Variable Global Symbols per Class Global Symbol pub variable_defs: HashMap>>, next_class_id: usize, @@ -46,6 +55,10 @@ impl GlobalSemanticModel { class_defs: HashMap::new(), method_defs: HashMap::new(), variable_defs: HashMap::new(), + properties: HashMap::new(), + property_defs: HashMap::new(), + parameter_defs: HashMap::new(), + parameters: HashMap::new(), next_class_id: 0, } } @@ -57,26 +70,172 @@ impl GlobalSemanticModel { variable: Variable, method_ref: MethodRef, scope_id: ScopeId, + var_dependencies: Vec, + variable_range: Range, + url: Url, ) -> VariableRef { - let scopes_to_vars = self.variables.entry(method_ref).or_insert(HashMap::new()); - let vars = scopes_to_vars.entry(scope_id).or_insert(Vec::new()); - let var_ref = VariableRef { - pub_id: Some(PublicVarId(vars.len())), + if variable.is_public { + let scopes_to_vars = self.variables.entry(method_ref).or_insert(HashMap::new()); + let vars = scopes_to_vars.entry(scope_id).or_insert(Vec::new()); + let var_ref = VariableRef { + pub_id: Some(PublicVarId(vars.len())), + priv_id: None, + }; + vars.push(variable); + self.new_variable_symbol(variable_range, url, var_dependencies, method_ref, scope_id); + return var_ref; + } else { + if let Some(lsm) = self.get_local_semantic_mut(&method_ref.class) { + return lsm.new_variable(method_ref, variable, scope_id); + } + } + eprintln!("Error: failed to add variable"); + return VariableRef { + pub_id: None, priv_id: None, }; - vars.push(variable); - var_ref + } + + /// Given a Property, adds the Property as the value of the `PropertyRef` key + pub fn new_property( + &mut self, + property: Property, + property_ref: PropertyRef, + property_range: Range, + url: Url, + ) { + if property.is_public { + self.new_property_symbol(property.name.clone(), property_range, url, property_ref); + self.properties.insert(property_ref, property); + } else { + if let Some(lsm) = self.get_local_semantic_mut(&property_ref.class) { + lsm.new_property(property, property_ref); + } + } + } + + // Finds the latest oref definition in a given scope + pub fn get_oref_in_scope_before_range( + &self, + method_ref: MethodRef, + scope_id: ScopeId, + variable_name: &str, + method_call_range: Range, + private_variable_symbols: &Vec, // the private variable symbols in a scope + ) -> Option<(Range, String)> { + let mut variable_definition: Option = None; + let mut oref_class = None; + let mut potential_variable_indices = HashSet::new(); + if let Some(variables) = self + .variables + .get(&method_ref) + .and_then(|scopes| scopes.get(&scope_id)) + { + for (i, variable) in variables.iter().enumerate() { + if variable.is_oref + && &variable.name == variable_name + && let Some(oref_cls) = &variable.cls + { + potential_variable_indices.insert((i, oref_cls)); + } + } + for (i, oref_cls) in &potential_variable_indices { + if let Some(variable_def) = self + .variable_defs + .get(&method_ref) + .and_then(|scopes| scopes.get(&scope_id)) + .and_then(|variables| variables.get(*i)) + { + if variable_def.location.end_byte < method_call_range.start_byte { + if let Some(curr_var_def) = variable_definition { + if variable_def.location.start_byte > curr_var_def.start_byte { + variable_definition = Some(variable_def.location); + oref_class = Some(*oref_cls); + } + } else { + variable_definition = Some(variable_def.location); + oref_class = Some(*oref_cls); + } + } + } + } + } + if variable_definition.is_none() { + if let Some(lsm) = self.get_local_semantic(&method_ref.class) + && let Some(variables) = lsm + .variables + .get(&method_ref) + .and_then(|scopes| scopes.get(&scope_id)) + { + for (i, variable) in variables.iter().enumerate() { + if variable.is_oref + && &variable.name == variable_name + && let Some(oref_cls) = &variable.cls + { + potential_variable_indices.insert((i, oref_cls)); + } + } + for (i, oref_cls) in potential_variable_indices { + if let Some(variable_def) = private_variable_symbols.get(i) { + if variable_def.location.end_byte < method_call_range.start_byte { + if let Some(curr_var_def) = variable_definition { + if variable_def.location.start_byte > curr_var_def.start_byte { + variable_definition = Some(variable_def.location); + oref_class = Some(oref_cls); + } + } else { + variable_definition = Some(variable_def.location); + oref_class = Some(oref_cls); + } + } + } + } + } + } + if let Some(var_range) = variable_definition + && let Some(oref_cls_name) = oref_class + { + return Some((var_range, oref_cls_name.clone())); + } + None + } + + /// Given a parameter, adds the parameter as the value of the `ParameterRef` key + pub fn new_parameter( + &mut self, + parameter: Parameter, + parameter_ref: ParameterRef, + parameter_range: Range, + url: Url, + ) { + self.new_parameter_symbol(parameter.name.clone(), parameter_range, url, parameter_ref); + self.parameters.insert(parameter_ref, parameter); } /// 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) { + pub fn new_class(&mut self, class: Class, class_id: ClassId, range: Range, url: Url) { + self.new_class_symbol(class.name.clone(), range, url, class_id); self.classes.insert(class_id, class); } - + // TODO ADD METHOD SYMBOL IN NEW_METHOD /// 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); + pub fn new_method( + &mut self, + method: Method, + method_ref: MethodRef, + method_range: Range, + url: Url, + ) { + if method.is_public { + let method_name = method.name.clone(); + self.methods.insert(method_ref, method); + self.new_method_symbol(method_name, method_range, url, method_ref); + } else { + if let Some(lsm) = self.get_local_semantic_mut(&method_ref.class) { + lsm.new_method(method, method_ref); + } + } } /// Inserts a new `LocalSemanticModel`, hashed by class name, to the global store `self.lsms` @@ -86,7 +245,7 @@ impl GlobalSemanticModel { /// 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. + /// and returns `None` if `lsm_id` is out of bounds. pub fn get_local_semantic_mut( &mut self, class_id: &ClassId, @@ -96,57 +255,206 @@ impl GlobalSemanticModel { /// 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. + /// 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. + /// returns `None` if `index` is out of bounds. pub fn get_class(&self, index: &ClassId) -> Option<&Class> { self.classes.get(index) } + /// Returns a mutable reference to the class at `index` in the classes table. + /// + /// returns `None` if `index` is out of bounds. + pub fn get_mut_class(&mut self, index: &ClassId) -> Option<&mut Class> { + self.classes.get_mut(index) + } + /// Returns the `ClassGlobalSymbol` at `index` in the class symbol table. /// - /// Logs a warning and returns `None` if `index` is out of bounds. + /// and returns `None` if `index` is out of bounds. pub fn get_class_symbol(&self, index: &ClassId) -> Option<&ClassGlobalSymbol> { self.class_defs.get(index) } + /// Returns a mutable ref to `ClassGlobalSymbol` at `index` in the class symbol table. + /// + /// and returns `None` if `index` is out of bounds. + pub fn get_class_symbol_mut(&mut self, index: &ClassId) -> Option<&mut ClassGlobalSymbol> { + self.class_defs.get_mut(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`. + /// Returns a mutable reference to the `method` corresponding to `MethodRef` if it exists, None otherwise. pub fn get_mut_method(&mut self, method_ref: &MethodRef) -> Option<&mut Method> { - self.methods.get_mut(method_ref) + if self.methods.contains_key(method_ref) { + return self.methods.get_mut(method_ref); + } + if let Some(lsm) = self.lsms.get_mut(&method_ref.class) { + return lsm.get_method_mut(method_ref); + } + None } /// 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`. + /// Returns an immutable referencce to the `method` corresponding to `MethodRef` if it exists, None otherwise. pub fn get_method(&self, method_ref: &MethodRef) -> Option<&Method> { - self.methods.get(method_ref) + if let Some(method) = self.methods.get(method_ref) { + return Some(method); + } else if let Some(lsm) = self.get_local_semantic(&method_ref.class) { + return lsm.get_method(method_ref); + } + return None; + } + + /// Removes `method` corresponding to `MethodRef` and returns it if it exists, None otherwise. + pub fn remove_method(&mut self, method_ref: &MethodRef) -> Option { + self.method_defs.remove(method_ref); + self.variable_defs.remove(method_ref); + self.variables.remove(method_ref); + if let Some(lsm) = self.get_local_semantic_mut(&method_ref.class) { + if let Some(method) = lsm.remove_method(method_ref) { + return Some(method); + } + } + self.methods.remove(method_ref) + } + + /// Removes `class` corresponding to `class_id`. + pub fn remove_class(&mut self, class_id: &ClassId) { + self.classes.remove(class_id); + } + + /// Removes `property` corresponding to `PropertyRef` and returns it if it exists, None otherwise. + pub fn remove_property(&mut self, property_ref: &PropertyRef) -> Option { + self.property_defs.remove(property_ref); + if let Some(property) = self.properties.remove(property_ref) { + return Some(property); + } else { + if let Some(lsm) = self.get_local_semantic_mut(&property_ref.class) { + return lsm.remove_property(property_ref); + } + } + return None; + } + + /// Returns the method symbol if it is now private. This will be added then to the scope tree. + pub fn change_method_publicity( + &mut self, + method_ref: &MethodRef, + method_range: Range, + url: Url, + ) -> Option { + if let Some(method) = self.remove_method(method_ref) { + if let Some(lsm) = self.get_local_semantic_mut(&method_ref.class) { + lsm.new_method(method, *method_ref); + } + return self.method_defs.remove(method_ref); + } else if let Some(lsm) = self.get_local_semantic_mut(&method_ref.class) { + if let Some(method) = lsm.remove_method(&method_ref) { + self.new_method(method, *method_ref, method_range, url); + } + } + return None; + } + + /// Removes `parameter` corresponding to `ParameterRef` and returns it if it exists, None otherwise. + pub fn remove_parameter(&mut self, parameter_ref: &ParameterRef) -> Option { + self.parameter_defs.remove(parameter_ref); + self.parameters.remove(parameter_ref) + } + + /// Fetches a mutable reference to a parameter by `ParameterRef`. + /// + /// Returns a mutable reference to the `parameter` corresponding to `ParameterRef` if it exists, None otherwise. + pub fn get_mut_parameter(&mut self, parameter_ref: &ParameterRef) -> Option<&mut Parameter> { + self.parameters.get_mut(parameter_ref) + } + + /// Fetches an immutable reference to a parameter by `ParameterRef`. + /// + /// Returns an immutable referencce to the `parameter` corresponding to `ParameterRef` if it exists, None otherwise. + pub fn get_parameter(&self, parameter_ref: &ParameterRef) -> Option<&Parameter> { + self.parameters.get(parameter_ref) + } + + /// Fetches a mutable reference to a ParameterSymbol by `ParameterRef`. + /// + /// Returns a mutable reference to the `ParameterSymbol` corresponding to `ParameterRef` if it exists, None otherwise. + pub fn get_mut_parameter_symbol( + &mut self, + parameter_ref: &ParameterRef, + ) -> Option<&mut ParameterSymbol> { + self.parameter_defs.get_mut(parameter_ref) + } + + /// Fetches an immutable reference to a parameter symbol by `ParameterRef`. + /// + /// Returns an immutable referencce to the `ParameterSymbol` corresponding to `ParameterRef` if it exists, None otherwise. + pub fn get_parameter_symbol(&self, parameter_ref: &ParameterRef) -> Option<&ParameterSymbol> { + self.parameter_defs.get(parameter_ref) + } + + /// Fetches a mutable reference to a property by `PropertyRef`. + /// + /// Looks up the corresponding property for `property_ref` and then indexes into it. Logs and returns `None` + /// if the class has no recorded property for `PropertyRef`. + pub fn get_mut_property(&mut self, property_ref: &PropertyRef) -> Option<&mut Property> { + self.properties.get_mut(property_ref) + } + + /// Fetches an immutable reference to a property by `PropertyRef`. + /// + /// Looks up the corresponding property for `property_ref` and then indexes into it. Logs and returns `None` + /// if the class has no recorded property for `PropertyRef`. + pub fn get_property(&self, property_ref: &PropertyRef) -> Option<&Property> { + self.properties.get(property_ref) + } + + /// Returns a mutable ref to the `PropertySymbol` for a class symbol by symbol index. + /// + /// Logs and returns `None` if the class has no property symbols recorded or `property_symbol_id` is + /// out of bounds. + pub fn get_property_symbol_mut( + &mut self, + property_symbol_ref: &PropertyRef, + ) -> Option<&mut PropertySymbol> { + self.property_defs.get_mut(property_symbol_ref) + } + + /// Returns an immutable ref to the `PropertySymbol` for a class symbol by symbol index. + /// + /// Logs and returns `None` if the class has no property symbols recorded or `property_symbol_id` is + /// out of bounds. + pub fn get_property_symbol( + &self, + property_symbol_ref: &PropertyRef, + ) -> Option<&PropertySymbol> { + self.property_defs.get(property_symbol_ref) } - /// Returns the `MethodGlobalSymbol` for a class symbol by symbol index. + /// Returns a mutable ref to the `MethodSymbol` 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> { + ) -> Option<&mut MethodSymbol> { self.method_defs.get_mut(method_symbol_ref) } - /// Returns the `MethodGlobalSymbol` for a class symbol by symbol index. + /// Returns an immutable ref to the `MethodSymbol` 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> { + pub fn get_method_symbol(&self, method_symbol_ref: &MethodRef) -> Option<&MethodSymbol> { self.method_defs.get(method_symbol_ref) } @@ -167,6 +475,14 @@ impl GlobalSemanticModel { None } + pub fn reset_method_semantics(&mut self, method_ref: &MethodRef) { + self.variables.remove(method_ref); + self.variable_defs.remove(method_ref); + if let Some(lsm) = self.get_local_semantic_mut(&method_ref.class) { + lsm.variables.remove(method_ref); + } + } + /// 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. @@ -191,42 +507,61 @@ impl GlobalSemanticModel { pub fn incremental_reset_doc_semantics( &mut self, class_id: &ClassId, - class_name: String, - methods_to_remove: Vec, + methods_to_remove: HashSet, + properties_to_remove: HashSet, + parameters_to_remove: HashSet, ) { - 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); + self.remove_method(method_ref); + } + for property_ref in &properties_to_remove { + self.remove_property(property_ref); + } + for parameter_ref in ¶meters_to_remove { + self.remove_parameter(parameter_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); + local_semantic_model.partial_clear(methods_to_remove, properties_to_remove); }; } /// Clears all semantic state associated with a re-parsed document. + /// Marks the class symbol as inactive and removes all method/variable symbols for the 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) { + pub fn reset_doc( + &mut self, + class_id: &ClassId, + class_name: String, + ) -> ( + HashMap, + HashMap, + HashMap, + ) { let Some(class) = self.classes.get_mut(class_id) else { eprintln!("Error: class named {:?} not found", class_name); - return; + return (HashMap::new(), HashMap::new(), HashMap::new()); }; + let old_methods = class.methods.clone(); + let old_properties = class.properties.clone(); + let old_parameters = class.parameters.clone(); for method_ref in class.methods.values() { - self.methods.remove(&method_ref); - self.variables.remove(&method_ref); + self.methods.remove(method_ref); + self.method_defs.remove(method_ref); + self.variables.remove(method_ref); + self.variable_defs.remove(method_ref); + } + for property_ref in class.properties.values() { + self.properties.remove(property_ref); + self.property_defs.remove(property_ref); + } + for parameter_ref in class.parameters.values() { + self.parameter_defs.remove(parameter_ref); + self.parameters.remove(parameter_ref); } class.clear(class_name.clone(), true); @@ -234,6 +569,12 @@ impl GlobalSemanticModel { if let Some(local_semantic_model) = self.lsms.get_mut(&class_id) { local_semantic_model.clear(); }; + let Some(class_symbol) = self.class_defs.get_mut(class_id) else { + eprintln!("Error: in reset_doc, class symbol not found"); + return (old_methods, old_properties, old_parameters); + }; + class_symbol.alive = false; + (old_methods, old_properties, old_parameters) } pub fn next_id(&mut self) -> usize { @@ -242,23 +583,6 @@ impl GlobalSemanticModel { 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, @@ -277,8 +601,8 @@ impl GlobalSemanticModel { 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) { + /// Creates a new class symbol entry. + fn new_class_symbol(&mut self, name: String, range: Range, url: Url, symbol_id: ClassId) { self.class_defs.insert( symbol_id, ClassGlobalSymbol { @@ -290,17 +614,51 @@ impl GlobalSemanticModel { ); } - /// 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( + /// Adds a new property symbol for PropertyRef. + fn new_property_symbol( + &mut self, + name: String, + range: Range, + url: Url, + property_symbol_ref: PropertyRef, + ) { + let property_symbol = PropertySymbol { + name, + url, + location: range, + references: Vec::new(), + }; + self.property_defs + .insert(property_symbol_ref, property_symbol); + } + + /// Adds a new Parameter symbol for ParameterRef. + fn new_parameter_symbol( + &mut self, + name: String, + range: Range, + url: Url, + parameter_symbol_ref: ParameterRef, + ) { + let parameter_symbol = ParameterSymbol { + name, + url, + location: range, + references: Vec::new(), + }; + self.parameter_defs + .insert(parameter_symbol_ref, parameter_symbol); + } + + /// Adds a new method symbol for `MethodRef`. + fn new_method_symbol( &mut self, name: String, range: Range, url: Url, method_symbol_ref: MethodRef, ) { - let method_symbol = MethodGlobalSymbol { + let method_symbol = MethodSymbol { name, url, location: range, @@ -310,88 +668,85 @@ impl GlobalSemanticModel { 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( + /// Adds a new variable symbol to the vec under `MethodRef`. + fn new_variable_symbol( &mut self, range: Range, url: Url, var_dependencies: Vec, method_symbol_ref: MethodRef, - variable_ref: VariableRef, scope_id: ScopeId, ) { let scopes_to_vars = self .variable_defs .entry(method_symbol_ref) .or_insert(HashMap::new()); - let defs = scopes_to_vars.entry(scope_id).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 { + scopes_to_vars + .entry(scope_id) + .or_insert(Vec::new()) + .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) { + pub fn class_keyword_inheritance(&mut self, name_to_id: &HashMap) { #[derive(Clone)] struct Snap { declared_pb: Option, declared_lang: Option, - primary_parent: Option, // leftmost only + declared_is_final: Option, + primary_parent: Option, } - let mut entries: Vec<(ClassId, &Class)> = - self.classes.iter().map(|(&id, c)| (id, c)).collect(); - entries.sort_by_key(|(id, _)| id.0); + let class_ids: Vec = self.classes.keys().copied().collect(); - let id_to_idx: HashMap = entries + let id_to_idx: HashMap = class_ids .iter() .enumerate() - .map(|(i, (id, _))| (*id, i)) + .map(|(i, &id)| (id, i)) .collect(); - let class_ids: Vec = entries.iter().map(|(id, _)| *id).collect(); - - let snaps: Vec = entries + let snaps: Vec = class_ids .iter() - .map(|(_, c)| Snap { - declared_pb: c.is_procedure_block, - declared_lang: c.default_language.clone(), - primary_parent: c.inherited_classes.get(0).copied(), + .map(|id| { + let c = &self.classes[id]; + Snap { + declared_pb: c.is_procedure_block, + declared_lang: c.default_language.clone(), + declared_is_final: c.is_final.clone(), + primary_parent: c + .inherited_classes + .get(0) + .and_then(|(name, _)| name_to_id.get(name)) + .copied(), + } }) .collect(); let n = snaps.len(); - let mut memo: Vec, Option)>> = vec![None; n]; + let mut memo: Vec, Option, 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)>>, + memo: &mut Vec, Option, Option)>>, state: &mut Vec, - ) -> (Option, Option) { + ) -> (Option, 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()); + return (s.declared_pb, s.declared_lang.clone(), s.declared_is_final); } state[idx] = DfsState::Visiting; @@ -401,30 +756,34 @@ impl GlobalSemanticModel { // start with declared values let mut pb = s.declared_pb; let mut lang = s.declared_lang.clone(); + let mut is_final = s.declared_is_final; // 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 let Some(parent_id) = s.primary_parent { + if let Some(&parent_idx) = id_to_idx.get(&parent_id) { + let (ppb, plang, pfinal) = dfs(parent_idx, snaps, id_to_idx, memo, state); if pb.is_none() { pb = ppb; } if lang.is_none() { lang = plang; } + if is_final.is_none() { + is_final = pfinal; + } } } } state[idx] = DfsState::Done; - memo[idx] = Some((pb, lang.clone())); - (pb, lang) + memo[idx] = Some((pb, lang.clone(), is_final)); + (pb, lang, is_final) } // ---- 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 (eff_pb, eff_lang, _eff_final) = 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; @@ -443,7 +802,7 @@ impl GlobalSemanticModel { /// /// 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 { + pub fn build_dependents(&self, name_to_id: &HashMap) -> Dependents { let mut entries: Vec<(ClassId, &Class)> = self.classes.iter().map(|(&id, c)| (id, c)).collect(); entries.sort_by_key(|(id, _)| id.0); @@ -455,28 +814,38 @@ impl GlobalSemanticModel { .collect(); let n = entries.len(); - let mut children: Vec> = vec![Vec::new(); n]; + let mut children = vec![Vec::new(); n]; + let mut index = Dependents::new(); 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); + for (parent_name, parent_range) in &cls.inherited_classes { + if let Some(&parent_id) = name_to_id.get(parent_name) { + if let Some(&parent_idx) = id_to_idx.get(&parent_id) { + if entries[parent_idx].1.active { + children[parent_idx].push((*child_id, parent_range.clone())); + } } } } } + for (i, (class_id, cls)) in entries.iter().enumerate() { + if !cls.active { + continue; + } + let direct = children[i].iter().cloned().collect(); + index.direct_subclasses.insert(*class_id, direct); + } + 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>, + children: &Vec>, id_to_idx: &HashMap, memo: &mut Vec>>, state: &mut Vec, @@ -494,9 +863,9 @@ impl GlobalSemanticModel { 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) { + 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)); } } @@ -510,156 +879,12 @@ impl GlobalSemanticModel { if !cls.active { continue; } - let mut dependents: Vec = dfs(i, &children, &id_to_idx, &mut memo, &mut state) + let dependents: HashSet = 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 index a356b62..f6af95e 100644 --- a/objectscript-lsp/crates/objectscript-core/src/lib.rs +++ b/objectscript-lsp/crates/objectscript-core/src/lib.rs @@ -7,7 +7,9 @@ pub mod global_semantic; pub mod local_semantic; pub mod method; pub mod override_index; +pub mod parameter; pub mod parse_structures; +pub mod property; pub mod refactor; pub mod scope_structures; pub mod scope_tree; diff --git a/objectscript-lsp/crates/objectscript-core/src/local_semantic.rs b/objectscript-lsp/crates/objectscript-core/src/local_semantic.rs index 614ceda..61f43b9 100644 --- a/objectscript-lsp/crates/objectscript-core/src/local_semantic.rs +++ b/objectscript-lsp/crates/objectscript-core/src/local_semantic.rs @@ -1,15 +1,15 @@ use crate::parse_structures::{ - ClassProperty, Method, MethodRef, PrivateVarId, Variable, VariableRef, + Method, MethodRef, PrivateVarId, Property, PropertyRef, Variable, VariableRef, }; use crate::scope_structures::ScopeId; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; /// 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 properties: HashMap, pub variables: HashMap>>, pub active: bool, } @@ -19,20 +19,39 @@ impl LocalSemanticModel { pub fn new() -> Self { Self { methods: HashMap::new(), - properties: Vec::new(), + properties: HashMap::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) { + /// Removes methods and their variables by MethodRef, and removes properties by their PropertyRef, leaving the rest intact. + pub fn partial_clear( + &mut self, + methods_to_clear: HashSet, + properties_to_remove: HashSet, + ) { for method_ref in methods_to_clear { - self.methods.remove(&method_ref); - self.variables.remove(&method_ref); + self.remove_method(&method_ref); + } + for property_ref in properties_to_remove { + self.remove_property(&property_ref); } } + /// Removes `method` corresponding to `method_ref` and removes all of its variables. + /// Returns `method` if it exists, None otherwise. + pub fn remove_method(&mut self, method_ref: &MethodRef) -> Option { + self.variables.remove(&method_ref); + self.methods.remove(&method_ref) + } + + /// Removes `property` corresponding to `property_ref`. + /// Returns `property` if it exists, None otherwise. + pub fn remove_property(&mut self, property_ref: &PropertyRef) -> Option { + self.properties.remove(&property_ref) + } + /// Returns a reference to a private variable by method ref and index. pub fn get_variable( &self, @@ -95,4 +114,25 @@ impl LocalSemanticModel { pub fn get_method_mut(&mut self, method_ref: &MethodRef) -> Option<&mut Method> { self.methods.get_mut(method_ref) } + + /// Fetches a mutable reference to a property by `PropertyRef`. + /// + /// Looks up the corresponding property for `property_ref` and then indexes into it. Logs and returns `None` + /// if the class has no recorded property for `PropertyRef`. + pub fn get_mut_property(&mut self, property_ref: &PropertyRef) -> Option<&mut Property> { + self.properties.get_mut(property_ref) + } + + /// Fetches an immutable reference to a property by `PropertyRef`. + /// + /// Looks up the corresponding property for `property_ref` and then indexes into it. Logs and returns `None` + /// if the class has no recorded property for `PropertyRef`. + pub fn get_property(&self, property_ref: &PropertyRef) -> Option<&Property> { + self.properties.get(property_ref) + } + + /// Given a Property, adds the Property to the vec corresponding to the class the Property is defined in. + pub fn new_property(&mut self, property: Property, property_ref: PropertyRef) { + self.properties.insert(property_ref, property); + } } diff --git a/objectscript-lsp/crates/objectscript-core/src/method.rs b/objectscript-lsp/crates/objectscript-core/src/method.rs index 9015cf3..f08f7e2 100644 --- a/objectscript-lsp/crates/objectscript-core/src/method.rs +++ b/objectscript-lsp/crates/objectscript-core/src/method.rs @@ -1,164 +1,123 @@ use crate::common::{ - find_return_type, find_var_dependencies, generic_skipping_statements, get_keyword_and_value, - get_node_children, get_string_at_byte_range, + find_return_type, find_var_dependencies, get_keyword_and_value, get_node_children, + get_string_at_byte_range, parse_line_ref, range_within_range, +}; +use crate::parse_structures::{ + CodeMode, Language, Method, MethodType, TypeName, UnresolvedMethodRef, Variable, }; -use crate::parse_structures::{CodeMode, Language, Method, MethodType, ReturnType, Variable}; -use std::collections::HashMap; +use crate::scope_structures::ScopeId; +use crate::scope_tree::ScopeTree; +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; use tree_sitter::{Language as TsLanguage, Node, Query, QueryCursor, Range, StreamingIterator}; use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; use tree_sitter_objectscript_routine::LANGUAGE_OBJECTSCRIPT_ROUTINE; -/// 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)) +const SET_VARIABLES_QUERY: &str = + "(command_set (set_argument [(set_target) (set_target_list)] @settarget (expression) @value ))"; + +const ROUTINE_ARGUMENT_QUERY: &str = "(tag_parameter (method_arg) @arg)"; + +const CLASS_METHOD_ARGUMENT_QUERY: &str = + "(argument (method_arg) @arg (return_type (typename) @typename)?)"; + +const METHOD_DEPENDENCY_QUERY: &str = r#"[(class_method_call) @classmethodcall +(system_defined_function) @systemfunc +(relative_dot_method) @relativemethod +(routine_tag_call) @routine +(goto_argument) @routine +(print_argument) @routine +]"#; + +const METHOD_KEYWORD_QUERY: &str = r#" + (method_definition ([(method_keyword_codemode_expression) @keyword + (method_keyword_external_language) @keyword + (method_keyword) @keyword + (call_method_keyword) @keyword + (return_type (typename (identifier) @returntype )) + ]))"#; + +fn cached_query( + query: &'static OnceLock, + language: TsLanguage, + source: &str, + name: &str, +) -> &'static Query { + query.get_or_init(|| { + Query::new(&language, source) + .unwrap_or_else(|error| panic!("failed to compile {name} Tree-sitter query: {error}")) + }) +} + +fn udl_set_variables_query() -> &'static Query { + static QUERY: OnceLock = OnceLock::new(); + cached_query( + &QUERY, + LANGUAGE_OBJECTSCRIPT_UDL.into(), + SET_VARIABLES_QUERY, + "UDL set variables", + ) +} + +fn routine_set_variables_query() -> &'static Query { + static QUERY: OnceLock = OnceLock::new(); + cached_query( + &QUERY, + LANGUAGE_OBJECTSCRIPT_ROUTINE.into(), + SET_VARIABLES_QUERY, + "routine set variables", + ) +} + +fn routine_argument_query() -> &'static Query { + static QUERY: OnceLock = OnceLock::new(); + cached_query( + &QUERY, + LANGUAGE_OBJECTSCRIPT_ROUTINE.into(), + ROUTINE_ARGUMENT_QUERY, + "routine argument", + ) +} + +fn class_method_argument_query() -> &'static Query { + static QUERY: OnceLock = OnceLock::new(); + cached_query( + &QUERY, + LANGUAGE_OBJECTSCRIPT_UDL.into(), + CLASS_METHOD_ARGUMENT_QUERY, + "class method argument", + ) +} + +fn udl_method_dependency_query() -> &'static Query { + static QUERY: OnceLock = OnceLock::new(); + cached_query( + &QUERY, + LANGUAGE_OBJECTSCRIPT_UDL.into(), + METHOD_DEPENDENCY_QUERY, + "UDL method dependency", + ) +} + +fn routine_method_dependency_query() -> &'static Query { + static QUERY: OnceLock = OnceLock::new(); + cached_query( + &QUERY, + LANGUAGE_OBJECTSCRIPT_ROUTINE.into(), + METHOD_DEPENDENCY_QUERY, + "routine method dependency", + ) +} + +fn method_keyword_query() -> &'static Query { + static QUERY: OnceLock = OnceLock::new(); + cached_query( + &QUERY, + LANGUAGE_OBJECTSCRIPT_UDL.into(), + METHOD_KEYWORD_QUERY, + "method keyword", + ) } impl Method { @@ -167,59 +126,167 @@ impl Method { /// 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, + public_variables: HashSet, 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, - } + return match method_type { + MethodType::Routine => Self { + method_type, + return_type: None, + name: method_name, + variables: HashMap::new(), + is_public: true, + is_procedure_block: Some(false), + language: None, + public_variables_declared: public_variables, + code_mode: CodeMode::Code, + is_final: Some(true), + }, + MethodType::Subroutine(is_public) | MethodType::DottedSubroutine(is_public) => Self { + method_type, + return_type: None, + name: method_name, + variables: HashMap::new(), + is_public: is_public, + is_procedure_block: Some(false), + language: None, + public_variables_declared: public_variables, + code_mode: CodeMode::Code, + is_final: Some(true), + }, + MethodType::Procedure(is_public) => Self { + method_type, + return_type: None, + name: method_name, + variables: HashMap::new(), + is_public: is_public, + is_procedure_block: Some(true), + language: None, + public_variables_declared: public_variables, + code_mode: CodeMode::Code, + is_final: Some(true), + }, + MethodType::ClassMethod | MethodType::InstanceMethod => Self { + method_type, + return_type: None, + name: method_name, + variables: HashMap::new(), + is_public: true, + is_procedure_block: None, + language: None, + public_variables_declared: public_variables, + code_mode: CodeMode::Code, + is_final: None, + }, + }; } - pub fn build_variables( + fn build_subroutine_set_variables( &self, node: Node, content: &str, - is_rtn: bool, - ) -> Vec<(Variable, Range, Vec)> { - let mut variables = Vec::new(); - let language: TsLanguage; - if is_rtn { - language = LANGUAGE_OBJECTSCRIPT_ROUTINE.into() - } else { - language = LANGUAGE_OBJECTSCRIPT_UDL.into(); + scope_tree: &ScopeTree, + variables_in_method: &mut Vec<(Variable, Range, Vec, ScopeId)>, + method_range: Range, + ) { + { + let query = routine_set_variables_query(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(query, node, content.as_bytes()); + while let Some(query_match) = iter.next() { + let set_target_node = query_match.captures[0].node; + if !range_within_range(&set_target_node.range(), &method_range) { + continue; + } + let mut var_defs = Vec::new(); + let mut var_deps = Vec::new(); + let var_value = query_match.captures[1].node; + let children; + if set_target_node.kind() == "set_target_list" { + children = get_node_children(set_target_node); + } else { + children = vec![set_target_node]; + } + for set_target in children { + let Some(set_target_child) = set_target.named_child(0) else { + eprintln!( + "Error: Expected child at index 0 for set_target node {:?}", + set_target.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() + ); + } + } + } + let (is_oref, curr_class) = + find_var_dependencies(var_value, content, &mut var_deps); + for (variable_name, var_range) in &var_defs { + let var = Variable::new( + variable_name.clone(), + None, + true, + is_oref, + curr_class.clone(), + ); + if let Some(scope_id) = scope_tree + .find_current_scope_for_range(var_range.start_point, var_range.end_point) + { + variables_in_method.push((var, *var_range, var_deps.clone(), scope_id)); + } + } + } } - let query_str = "(argument (method_arg) @arg (return_type (typename) @return)?)"; - variables.extend(self.get_method_arguments(&language, query_str, node, content)); - - let query_str = "(command_set (set_argument [(set_target) (set_target_list)] @settarget (expression) @value ))"; - variables.extend(self.get_set_command_variable_defs(&language, query_str, node, content)); - variables } - fn get_set_command_variable_defs( + fn build_procedure_set_variables( &self, - language: &TsLanguage, - query_str: &str, node: Node, content: &str, - ) -> Vec<(Variable, Range, Vec)> { - let mut variables = Vec::new(); - if let Ok(query) = Query::new(language, query_str) { + scope_tree: &ScopeTree, + variables_in_method: &mut Vec<(Variable, Range, Vec, ScopeId)>, + class_is_procedure_block: Option, + is_class_method: bool, + ) { + { + let query = if is_class_method { + udl_set_variables_query() + } else { + routine_set_variables_query() + }; let mut cursor = QueryCursor::new(); - let mut iter = cursor.matches(&query, node, content.as_bytes()); + let mut iter = cursor.matches(query, node, content.as_bytes()); while let Some(query_match) = iter.next() { let mut var_defs = Vec::new(); let mut var_deps = Vec::new(); @@ -276,100 +343,701 @@ impl Method { } let (is_oref, curr_class) = find_var_dependencies(var_value, 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(), + for (variable_name, var_range) in &var_defs { + let variable_is_public = if !is_class_method { + if self.public_variables_declared.contains(variable_name) { + true + } else { + false + } + } else { + self.is_procedure_block + .unwrap_or(class_is_procedure_block.unwrap_or(true)) + == false + || self.public_variables_declared.contains(variable_name) + }; + let var = Variable::new( + variable_name.clone(), None, - is_public, + variable_is_public, is_oref, curr_class.clone(), ); - variables.push((variable, *v_range, var_deps.clone())); + if let Some(scope_id) = scope_tree + .find_current_scope_for_range(var_range.start_point, var_range.end_point) + { + variables_in_method.push((var, *var_range, var_deps.clone(), scope_id)); + } } } } - variables } - fn get_method_arguments( + /// Given tag node, parse the arguments + fn build_routine_method_arguments( &self, - language: &TsLanguage, - query_str: &str, - node: Node, + tag_node: Node, content: &str, - ) -> Vec<(Variable, Range, Vec)> { - let mut variables = Vec::new(); - if let Ok(query) = Query::new(language, query_str) { + scope_tree: &ScopeTree, + variables_in_method: &mut Vec<(Variable, Range, Vec, ScopeId)>, + is_procedure: bool, // false if subroutine + ) { + { + let query = routine_argument_query(); let mut cursor = QueryCursor::new(); - let mut iter = cursor.matches(&query, node, content.as_bytes()); + let mut iter = cursor.matches(query, tag_node, content.as_bytes()); while let Some(query_match) = iter.next() { - let name; - let var_range; - let mut arg_type = None; - let method_arg; - let mut return_type = None; - method_arg = query_match.captures[0].node; - if query_match.captures.len() > 1 { - return_type = Some(query_match.captures[1].node); - } + let method_arg = query_match.captures[0].node; if let Some(method_arg_type) = method_arg.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 {:?}", + "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()); + if let Some(var_name) = + get_string_at_byte_range(content, variable_name_node.byte_range()) + { + let var_range = variable_name_node.range(); + let variable_is_public = if !is_procedure + || self.public_variables_declared.contains(&var_name) + { + true + } else { + false + }; + let var = Variable::new(var_name, None, variable_is_public, false, None); + if let Some(scope_id) = scope_tree.find_current_scope_for_range( + var_range.start_point, + var_range.end_point, + ) { + variables_in_method.push((var, var_range, Vec::new(), scope_id)); + } + } } else { eprintln!( "Error: Method arg node should have named children. This node didn't {:?}", method_arg.kind() ); - continue; } - if let Some(return_type) = return_type - && let Some(return_type_str) = - get_string_at_byte_range(content, return_type.byte_range()) - && return_type.kind() == "typename" - { - arg_type = Some(find_return_type(return_type_str)); + continue; + } + } + } + + fn build_class_method_arguments( + &self, + node: Node, + content: &str, + scope_tree: &ScopeTree, + variables_in_method: &mut Vec<(Variable, Range, Vec, ScopeId)>, + class_is_procedure_block: Option, + ) { + { + let query = class_method_argument_query(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(query, node, content.as_bytes()); + let arg_idx = query.capture_index_for_name("arg"); + let typename_idx = query.capture_index_for_name("typename"); + let mut return_type_parameters = Vec::new(); + let mut return_type_id = None; + let mut var_range = None; + let mut var_name = None; + while let Some(query_match) = iter.next() { + let mut i = 0; + let mut arg_type = None; + while i < query_match.captures.len() { + let capture = &query_match.captures[i]; + if arg_idx == Some(capture.index) { + let method_arg = capture.node; + if let Some(method_arg_type) = method_arg.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; + }; + var_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 {:?}", + method_arg.kind() + ); + } + i += 1; + continue; + } else if typename_idx == Some(capture.index) { + let typename = capture.node; + let identifiers = get_node_children(typename); + let mut j = 0; + while j < identifiers.len() { + let identifier_node = &identifiers[j]; + let Some(typename_identifier) = + get_string_at_byte_range(content, identifier_node.byte_range()) + else { + j += 1; + continue; + }; + if j == 0 { + return_type_id = Some(find_return_type(typename_identifier)); + } else { + return_type_parameters.push(typename_identifier); + } + j += 1; + continue; + } + if let Some(typename_id) = &return_type_id { + arg_type = Some(TypeName { + ret_type: typename_id.clone(), + parameters: return_type_parameters.clone(), + }) + } + + i += 1; + continue; + } + i += 1; } - if let Some(var_name) = name - && let Some(var_name_range) = var_range + if let Some(var_name) = &var_name + && let Some(var_range) = var_range { - if self.is_procedure_block.unwrap_or(true) == false - || self.public_variables_declared.contains(&var_name) + let variable_is_public = self + .is_procedure_block + .unwrap_or(class_is_procedure_block.unwrap_or(true)) + == false + || self.public_variables_declared.contains(var_name); + let var = + Variable::new(var_name.clone(), arg_type, variable_is_public, false, None); + if let Some(scope_id) = scope_tree + .find_current_scope_for_range(var_range.start_point, var_range.end_point) { - 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())); + variables_in_method.push((var, var_range, Vec::new(), scope_id)); } } } } - 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); + fn get_method_dependencies( + &mut self, + node: Node, + content: &str, + is_class_method: bool, + class_name: &str, + method_range: Range, + ) -> ( + HashSet, + HashSet<(String, String, Range, String)>, + ) { + let mut unresolved_method_refs = HashSet::new(); + let mut unresolved_oref_method_refs = HashSet::new(); + { + let query = if is_class_method { + udl_method_dependency_query() + } else { + routine_method_dependency_query() + }; + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(query, node, content.as_bytes()); + let classmethod_idx = query.capture_index_for_name("classmethodcall"); + let systemfunc_idx = query.capture_index_for_name("systemfunc"); + let relativemethod_idx = query.capture_index_for_name("relativemethod"); + let routine_idx = query.capture_index_for_name("routine"); + while let Some(query_match) = iter.next() { + let mut i = 0; + while i < query_match.captures.len() { + let capture = &query_match.captures[i]; + let matched_node = capture.node; + if !range_within_range(&matched_node.range(), &method_range) { + i += 1; + continue; + } + if classmethod_idx == Some(capture.index) { + if let Some(class_ref) = matched_node.named_child(0) + && let Some(method_name_outer) = matched_node.named_child(1) + && let Some(class_name_outer) = class_ref.named_child(1) + && let Some(method_name_node) = method_name_outer.named_child(0) + && let Some(class_name_node) = class_name_outer.named_child(0) + && let Some(method_name) = + get_string_at_byte_range(content, method_name_node.byte_range()) + && let Some(class_name) = + get_string_at_byte_range(content, class_name_node.byte_range()) + { + unresolved_method_refs.insert(UnresolvedMethodRef { + class: class_name, + method: method_name, + offset: None, + method_call_range: matched_node.range(), + }); + } + } else if systemfunc_idx == Some(capture.index) { + let Some(node_str) = + get_string_at_byte_range(content, matched_node.byte_range()) + else { + i += 1; + 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") + { + if let Some(oref_method_arg) = matched_node.named_child(0) + && let Some(oref_method_arg_type) = + oref_method_arg.named_child(0) + && let Some(oref_name_node) = + oref_method_arg_type.named_child(0) + && let Some(oref_var_name) = get_string_at_byte_range( + content, + oref_name_node.byte_range(), + ) + && let Some(method_name_method_arg) = + matched_node.named_child(1) + && let Some(method_name_arg_type) = + method_name_method_arg.named_child(0) + && let Some(method_name_node) = + method_name_arg_type.named_child(0) + && let Some(method_name) = get_string_at_byte_range( + content, + method_name_node.byte_range(), + ) + { + unresolved_oref_method_refs.insert(( + oref_var_name, + method_name, + matched_node.range(), + self.name.clone(), + )); + } + } else if func_name.eq_ignore_ascii_case("$classmethod") + || func_name.eq_ignore_ascii_case("$zobjclassmethod") + { + if method_args.trim_start().chars().next() == Some(',') { + // class is current one + if let Some(method_name_method_arg) = + matched_node.named_child(0) + && let Some(method_name_arg_type) = + method_name_method_arg.named_child(0) + && let Some(method_name_node) = + method_name_arg_type.named_child(0) + && let Some(method_name) = get_string_at_byte_range( + content, + method_name_node.byte_range(), + ) + { + unresolved_method_refs.insert(UnresolvedMethodRef { + class: class_name.to_string(), + method: method_name, + offset: None, + method_call_range: matched_node.range(), + }); + } + } else { + if let Some(classname_method_arg) = matched_node.named_child(0) + && let Some(classname_method_arg_type) = + classname_method_arg.named_child(0) + && let Some(classname_node) = + classname_method_arg_type.named_child(0) + && let Some(classname_var) = get_string_at_byte_range( + content, + classname_node.byte_range(), + ) + && let Some(method_name_method_arg) = + matched_node.named_child(1) + && let Some(method_name_arg_type) = + method_name_method_arg.named_child(0) + && let Some(method_name_node) = + method_name_arg_type.named_child(0) + && let Some(method_name) = get_string_at_byte_range( + content, + method_name_node.byte_range(), + ) + { + unresolved_method_refs.insert(UnresolvedMethodRef { + class: classname_var, + method: method_name, + offset: None, + method_call_range: matched_node.range(), + }); + } + } + } 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(classname) = get_string_at_byte_range( + content, + class_name_node.byte_range(), + ) + && let Some(method_name) = get_string_at_byte_range( + content, + method_name_node.byte_range(), + ) + { + unresolved_method_refs.insert(UnresolvedMethodRef { + class: classname, + method: method_name, + offset: None, + method_call_range: matched_node.range(), + }); + } + } + } + } else if relativemethod_idx == Some(capture.index) { + 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()) + { + unresolved_method_refs.insert(UnresolvedMethodRef { + class: class_name.to_string(), + method: method_name, + offset: None, + method_call_range: matched_node.range(), + }); + } + } else if routine_idx == Some(capture.index) { + if let Some(routine_tag_call_child) = matched_node.named_child(0) { + match routine_tag_call_child.kind() { + "method_name" => { + // this version doesn't have wrapped in quotes option + if let Some(method_name) = + get_string_at_byte_range(content, matched_node.byte_range()) + { + unresolved_method_refs.insert(UnresolvedMethodRef { + class: class_name.to_string(), + method: method_name, + offset: None, + method_call_range: matched_node.range(), + }); + } + } + "line_ref" => { + let (routine_name, method_name, offset) = parse_line_ref( + routine_tag_call_child, + content, + class_name.to_string(), + ); + + unresolved_method_refs.insert(UnresolvedMethodRef { + class: routine_name, + method: method_name, + offset, + method_call_range: matched_node.range(), + }); + } + _ => { + i += 1; + continue; + } + } + } + } + i += 1; + continue; + } + } + } + (unresolved_method_refs, unresolved_oref_method_refs) + } + + /// Build Method Keywords and Body + pub fn rebuild_method( + &mut self, + node: Node, + content: &str, + scope_tree: &ScopeTree, + method_type: MethodType, + method_range: Range, + public_variables_declared: HashSet, // only procedure passes this + class_is_final: Option, + old_class_is_final: Option, + class_is_procedure_block: Option, + class_name: &str, + ) -> ( + bool, + bool, + Vec<(Variable, Range, Vec, ScopeId)>, + HashSet, + HashSet<(String, String, Range, String)>, + ) { + self.reset_method_keywords(method_type, public_variables_declared); + let mut variables_in_method = Vec::new(); + match method_type { + MethodType::Routine => { + self.build_routine_method_arguments( + node, + content, + scope_tree, + &mut variables_in_method, + false, + ); + self.build_subroutine_set_variables( + node, + content, + scope_tree, + &mut variables_in_method, + method_range, + ); + let (unresolved_method_refs, unresolved_oref_method_refs) = + self.get_method_dependencies(node, content, false, class_name, method_range); + ( + false, + false, + variables_in_method, + unresolved_method_refs, + unresolved_oref_method_refs, + ) + } + MethodType::Subroutine(is_public) | MethodType::DottedSubroutine(is_public) => { + let is_public_changed = self.is_public != is_public; + self.build_routine_method_arguments( + node, + content, + scope_tree, + &mut variables_in_method, + false, + ); + self.build_subroutine_set_variables( + node, + content, + scope_tree, + &mut variables_in_method, + method_range, + ); + let (unresolved_method_refs, unresolved_oref_method_refs) = + self.get_method_dependencies(node, content, false, class_name, method_range); + + ( + false, + is_public_changed, + variables_in_method, + unresolved_method_refs, + unresolved_oref_method_refs, + ) + } + MethodType::Procedure(is_public) => { + let is_public_changed = self.is_public != is_public; + self.build_procedure_set_variables( + node, + content, + scope_tree, + &mut variables_in_method, + class_is_procedure_block, + false, + ); + self.build_routine_method_arguments( + node, + content, + scope_tree, + &mut variables_in_method, + true, + ); + let (unresolved_method_refs, unresolved_oref_method_refs) = + self.get_method_dependencies(node, content, false, class_name, method_range); + ( + false, + is_public_changed, + variables_in_method, + unresolved_method_refs, + unresolved_oref_method_refs, + ) + } + MethodType::ClassMethod | MethodType::InstanceMethod => { + let (is_final_changed, is_public_changed) = + self.build_method_keywords(node, content, class_is_final, old_class_is_final); + self.build_class_method_arguments( + node, + content, + scope_tree, + &mut variables_in_method, + class_is_procedure_block, + ); + self.build_procedure_set_variables( + node, + content, + scope_tree, + &mut variables_in_method, + class_is_procedure_block, + true, + ); + let (unresolved_method_refs, unresolved_oref_method_refs) = + self.get_method_dependencies(node, content, true, class_name, method_range); + return ( + is_final_changed, + is_public_changed, + variables_in_method, + unresolved_method_refs, + unresolved_oref_method_refs, + ); + } + } + } + + pub fn reset_method_keywords( + &mut self, + method_type: MethodType, + public_variables_declared: HashSet, + ) { + self.method_type = method_type; + match method_type { + MethodType::Routine => { + self.return_type = None; + self.variables.clear(); + self.public_variables_declared = public_variables_declared; + } + MethodType::Procedure(is_public) + | MethodType::Subroutine(is_public) + | MethodType::DottedSubroutine(is_public) => { + self.return_type = None; + self.variables.clear(); + self.public_variables_declared = public_variables_declared; + self.is_public = is_public; + } + MethodType::ClassMethod | MethodType::InstanceMethod => { + self.return_type = None; + self.variables.clear(); + self.is_public = true; + self.is_procedure_block = None; + self.language = None; + self.public_variables_declared = HashSet::new(); + self.code_mode = CodeMode::Code; + self.is_final = None; + } } + } - if self.language.is_none() { - // inherit the class keyword when it isn't the default - self.language = Some(default_language.clone()); + fn build_method_keywords( + &mut self, + node: Node, + content: &str, + class_is_final: Option, + old_class_is_final: Option, + ) -> (bool, bool) { + // reset keywords to default based on method type + let mut is_final_changed = false; + let mut privacy_changed = false; + { + let query = method_keyword_query(); + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(query, node, content.as_bytes()); + let keyword_idx = query.capture_index_for_name("keyword"); + let returntype_idx = query.capture_index_for_name("returntype"); + let old_is_public = self.is_public.clone(); + let old_is_final = self.is_final.clone(); + let mut return_type_parameters = Vec::new(); + let mut saw_first_return_type = false; + let mut return_type_id = None; + while let Some(query_match) = iter.next() { + let mut i = 0; + while i < query_match.captures.len() { + let capture = &query_match.captures[i]; + if keyword_idx == Some(capture.index) { + if let Some(keyword_str) = + get_string_at_byte_range(content, capture.node.byte_range()) + { + let (not, keyword_name, values) = + get_keyword_and_value(keyword_str.as_str()); + if keyword_name == "final" && !class_is_final.unwrap_or(false) { + if not { + self.is_final = Some(false); + } else { + self.is_final = Some(true); + } + } else if keyword_name == "private" { + if not { + self.is_public = true; + } else { + self.is_public = false; + } + } else if keyword_name == "procedureblock" { + if let Some(value) = values.get(0).copied() { + if value == "1" { + self.is_procedure_block = Some(true); + } else if value == "0" { + self.is_procedure_block = Some(false); + } + } else { + self.is_procedure_block = Some(true); + } + } else if keyword_name == "codemode" { + let Some(value) = values.get(0).copied() else { + eprintln!("Expected a value for language keyword, got: None"); + i += 1; + continue; + }; + if value == "call" { + self.code_mode = CodeMode::Call; + } else if value == "code" { + self.code_mode = CodeMode::Code; + } else if value == "expression" { + self.code_mode = CodeMode::Expression; + } else if value == "objectgenerator" { + self.code_mode = CodeMode::ObjectGenerator; + } + } else if keyword_name == "publiclist" { + for variable in values { + self.public_variables_declared.insert(variable.to_string()); + } + } else if keyword_name == "language" { + if let Some(value) = values.get(0).copied() { + if value == "objectscript" { + self.language = Some(Language::Objectscript); + } else if value == "tsql" { + self.language = Some(Language::TSql); + } else if value == "ispl" { + self.language = Some(Language::ISpl); + } else if value == "python" { + self.language = Some(Language::Python); + } + } + } + } + i += 1; + continue; + } else if returntype_idx == Some(capture.index) { + let return_type_node = capture.node; + let Some(typename) = + get_string_at_byte_range(content, return_type_node.byte_range()) + else { + i += 1; + continue; + }; + if !saw_first_return_type { + return_type_id = Some(find_return_type(typename)); + saw_first_return_type = true; + } else { + return_type_parameters.push(typename); + } + i += 1; + continue; + } + i += 1; + } + } + if let Some(typename_id) = return_type_id { + let typename = TypeName { + ret_type: typename_id, + parameters: return_type_parameters, + }; + self.return_type = Some(typename); + } + let old_final_keyword_res = old_is_final.unwrap_or(old_class_is_final.unwrap_or(false)); + let new_final_keyword = self.is_final.unwrap_or(class_is_final.unwrap_or(false)); + if old_final_keyword_res != new_final_keyword { + is_final_changed = true; + } + if old_is_public != self.is_public { + privacy_changed = true; + } } + (is_final_changed, privacy_changed) } } diff --git a/objectscript-lsp/crates/objectscript-core/src/override_index.rs b/objectscript-lsp/crates/objectscript-core/src/override_index.rs index 547042e..22a562f 100644 --- a/objectscript-lsp/crates/objectscript-core/src/override_index.rs +++ b/objectscript-lsp/crates/objectscript-core/src/override_index.rs @@ -1,42 +1,54 @@ -use crate::parse_structures::{ClassId, MethodRef}; +use crate::parse_structures::{MethodRef, ParameterRef, PropertyRef}; use std::collections::HashMap; /// Stores information about what superclass methods get overwritten, and by which subclass. -/// Stores the public methods available for each class. +/// Stores the methods available for each class. /// For completion / resolution, this must be built after inheritance + overrides -#[derive(Default, Debug)] +#[derive(Default, Debug, Clone)] pub struct OverrideIndex { - /// Stores the Method Id that a class sees for each public method name - pub effective_public_methods: HashMap>, + /// Stores the MethodRef that a class sees for each method name (keyed by class name) + pub effective_methods: HashMap>, + + /// Stores the PropertyRef that a class sees for each property name (keyed by class name) + pub effective_properties: HashMap>, + + /// Stores the ParameterRef that a class sees for each parameter name (keyed by class name) + pub effective_parameters: HashMap>, /// subclass method ref (the method that overwites the superclass one) -> superclass method ref - pub overrides: HashMap, + pub method_overrides: HashMap, /// superclass method ref -> subclass method refs (subclass methods that overwrote the superclass) - pub overridden_by: HashMap>, + pub method_overridden_by: HashMap>, + + /// subclass property ref (the property that overwites the superclass one) -> superclass property ref + pub property_overrides: HashMap, + + /// superclass property ref -> subclass property refs (subclass property that overwrote the superclass) + pub property_overridden_by: HashMap>, + + /// subclass parameter ref (the parameter that overwites the superclass one) -> superclass parameter ref + pub parameter_overrides: HashMap, + + /// superclass parameter ref -> subclass ParameterRef (subclass parameter that overwrote the superclass) + pub parameter_overridden_by: HashMap>, } impl OverrideIndex { /// Creates an empty `OverrideIndex` with all maps initialized. /// - /// This index is typically populated after computing inheritance and resolving overrides. + /// This index is typically populated after computing inheritance and resolving class member 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(), + effective_methods: HashMap::new(), + method_overrides: HashMap::new(), + method_overridden_by: HashMap::new(), + property_overrides: HashMap::new(), + property_overridden_by: HashMap::new(), + effective_properties: HashMap::new(), + parameter_overridden_by: HashMap::new(), + parameter_overrides: HashMap::new(), + effective_parameters: HashMap::new(), } } } diff --git a/objectscript-lsp/crates/objectscript-core/src/parameter.rs b/objectscript-lsp/crates/objectscript-core/src/parameter.rs new file mode 100644 index 0000000..0b6a320 --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/parameter.rs @@ -0,0 +1,93 @@ +use crate::common::{find_return_type, get_keyword_and_value, get_string_at_byte_range}; +use crate::parse_structures::{Parameter, TypeName}; +use tree_sitter::{Language as TsLanguage, Node, Query, QueryCursor, StreamingIterator}; +use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; + +impl Parameter { + pub fn new(name: String) -> Self { + Self { + name, + is_final: None, + return_type: None, + } + } + + /// Given a parameter node, queries the keywords and assigns + /// the return type and the keywords + /// Returns (bool, bool) representing (is_public_changed, is_final_changed) + pub fn build_keywords( + &mut self, + node: Node, + content: &str, + old_class_is_final: Option, + class_is_final: Option, + ) -> bool { + let query_str = r#" + [(parameter_keyword) @keyword + (return_type (typename (identifier) @returntype )) + ]"#; + let language: &TsLanguage = &LANGUAGE_OBJECTSCRIPT_UDL.into(); + let mut is_final_changed = false; + if let Ok(query) = Query::new(language, query_str) { + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(&query, node, content.as_bytes()); + let keyword_idx = query.capture_index_for_name("keyword"); + let returntype_idx = query.capture_index_for_name("returntype"); + let old_is_final = self.is_final.clone(); + let mut return_type_parameters = Vec::new(); + let mut saw_first_return_type = false; + let mut return_type_id = None; + while let Some(query_match) = iter.next() { + let mut i = 0; + while i < query_match.captures.len() { + let capture = &query_match.captures[i]; + if keyword_idx == Some(capture.index) { + if let Some(keyword_str) = + get_string_at_byte_range(content, capture.node.byte_range()) + { + let (not, keyword_name, _) = + get_keyword_and_value(keyword_str.as_str()); + if keyword_name == "final" { + if not { + self.is_final = Some(false); + } else { + self.is_final = Some(true); + } + } + } + i += 1; + continue; + } else if returntype_idx == Some(capture.index) { + let return_type_node = capture.node; + let Some(typename) = + get_string_at_byte_range(content, return_type_node.byte_range()) + else { + continue; + }; + if !saw_first_return_type { + return_type_id = Some(find_return_type(typename)); + saw_first_return_type = true; + } else { + return_type_parameters.push(typename); + } + i += 1; + continue; + } + } + } + if let Some(typename_id) = return_type_id { + let typename = TypeName { + ret_type: typename_id, + parameters: return_type_parameters, + }; + self.return_type = Some(typename); + } + let old_final_keyword_res = old_is_final.unwrap_or(old_class_is_final.unwrap_or(false)); + let new_final_keyword = self.is_final.unwrap_or(class_is_final.unwrap_or(false)); + if old_final_keyword_res != new_final_keyword { + is_final_changed = true; + } + } + is_final_changed + } +} diff --git a/objectscript-lsp/crates/objectscript-core/src/parse_structures.rs b/objectscript-lsp/crates/objectscript-core/src/parse_structures.rs index 581713d..caaee23 100644 --- a/objectscript-lsp/crates/objectscript-core/src/parse_structures.rs +++ b/objectscript-lsp/crates/objectscript-core/src/parse_structures.rs @@ -1,13 +1,14 @@ use crate::scope_structures::ScopeId; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::hash::Hash; use std::hash::Hasher; +use tower_lsp::lsp_types::Range as LspRange; 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()`. +/// Stores the Method Index, which is assigned by `class.get_next_method_id()`. #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] pub struct MethodId(pub usize); @@ -19,32 +20,25 @@ pub struct PublicVarId(pub usize); #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] pub struct PrivateVarId(pub usize); -/// Stores the index into the per-class property vec in `Class`. +/// Stores the Property Index, which is assigned by `class.get_next_property_id()`. #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] pub struct PropertyId(pub usize); -/// Stores the index into the per-class parameter vec in `Class`. +/// Stores the Parameter Index, which is assigned by `class.get_next_parameter_id()`. #[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, + ClassDefRange, Relationship, Foreignkey, - Parameter, + RelativeParameter, + ParameterDef, + OrefParameter, Projection, Index, Xdata, @@ -53,7 +47,9 @@ pub enum MemberType { RelativeMethodCall, Query, Trigger, - Property, + RelativeProperty, + OrefProperty, + PropertyDef, OrefMethod, RoutineMethodCall, Routine, @@ -61,6 +57,9 @@ pub enum MemberType { SystemMember, GlobalVariable, MethodDef, + Keyword, + Procedure, + DottedStatementTag, } /// DFS visitation state. @@ -71,9 +70,7 @@ pub enum DfsState { 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. +/// Reference to a method implementation in a class. #[derive(Copy, Clone, Debug)] pub struct MethodRef { pub class: ClassId, @@ -81,6 +78,55 @@ pub struct MethodRef { pub offset: Option, } +/// Unresolved Reference to a method implementation in a class. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct UnresolvedMethodRef { + pub class: String, // unresolved class name + pub method: String, // unresolved method name + pub offset: Option, + pub method_call_range: Range, +} + +/// Reference to a parameter in a class. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct ParameterRef { + pub class: ClassId, + pub id: ParameterId, +} + +/// Reference to a property in a class. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub struct PropertyRef { + pub class: ClassId, + pub id: PropertyId, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Parameter { + /// If true, the Parameter cannot be overwritten by subclasses. + pub is_final: Option, + /// Parameter Name. + pub name: String, + /// Expected return type. + pub return_type: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Property { + /// Whether property is required or not. + pub required: bool, + /// Whether property is public or not. + pub is_public: bool, + /// If true, the property cannot be overwritten by subclasses. + pub is_final: Option, + /// Property Name. + pub name: String, + /// Whether property is multidimensional or not. + pub multidimensional: bool, + /// Expected return type. + pub return_type: Option, +} + impl PartialEq for MethodRef { fn eq(&self, other: &Self) -> bool { self.class == other.class && self.id == other.id @@ -98,6 +144,7 @@ impl Hash for MethodRef { // 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)] @@ -105,33 +152,35 @@ pub struct Class { /// Class Name. pub name: String, /// Imported classes referenced by this class. - pub imports: Vec, // list of class names + 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, + pub inherited_classes: Vec<(String, LspRange)>, /// Inheritance conflict resolution direction (`left`, or `right`, default is `left`). - pub inheritance_direction: String, + pub inheritance_direction: Option, /// 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 property name -> id for each property in this class. + pub properties: HashMap, /// Stores parameter name -> id for each parameter in this class. - pub parameters: HashMap, + 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, + pub(crate) next_parameter_id: usize, + pub(crate) next_property_id: usize, + /// If true, this class and all of its members cannot be overwritten by subclasses. + pub is_final: Option, } /// Language keyword values supported for classes/methods. @@ -143,15 +192,6 @@ pub enum Language { 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 { @@ -162,12 +202,13 @@ pub struct ClassParameter { } /// Distinguishes instance methods from class methods. -#[derive(Clone, Debug, Eq, PartialEq, Hash)] +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] pub enum MethodType { InstanceMethod, ClassMethod, - Procedure, - Subroutine, + Procedure(bool), + Subroutine(bool), + DottedSubroutine(bool), Routine, } @@ -184,7 +225,7 @@ pub struct Method { /// Class Method or Instance Method. pub method_type: MethodType, /// Expected return type. - pub return_type: Option, + pub return_type: Option, /// Method Name. pub name: String, /// Stores variable name -> VariableRef for all variable definitions in this method. @@ -198,7 +239,9 @@ pub struct Method { /// 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, + pub public_variables_declared: HashSet, + /// If true, this method cannot be overwritten by subclasses. + pub is_final: Option, } /// CodeMode keyword values supported for methods. @@ -225,7 +268,7 @@ 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, + 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. @@ -234,6 +277,12 @@ pub struct Variable { pub cls: Option, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TypeName { + pub ret_type: ReturnType, + pub parameters: Vec, +} + /// Normalized return/type categories recognized. #[derive(Clone, Debug, Eq, PartialEq)] pub enum ReturnType { @@ -254,10 +303,17 @@ pub enum ReturnType { HttpResponse, Other(String), SqlQuery, + ClassName, + CosCode, + CosIdentifier, + SqlIdentifier, + ConfigValue, + Variable, + Expression, } /// File type for a workspace document. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq, Copy)] pub enum FileType { Cls, Routine, diff --git a/objectscript-lsp/crates/objectscript-core/src/property.rs b/objectscript-lsp/crates/objectscript-core/src/property.rs new file mode 100644 index 0000000..2cd58ac --- /dev/null +++ b/objectscript-lsp/crates/objectscript-core/src/property.rs @@ -0,0 +1,119 @@ +use crate::common::{find_return_type, get_keyword_and_value, get_string_at_byte_range}; +use crate::parse_structures::{Property, TypeName}; +use tree_sitter::{Language as TsLanguage, Node, Query, QueryCursor, StreamingIterator}; +use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; +impl Property { + pub fn new(name: String) -> Self { + Self { + name, + required: false, + is_public: true, + multidimensional: false, + return_type: None, + is_final: Some(false), + } + } + + /// Given a property node, queries the keywords and assigns + /// the return type and the keywords + /// Returns (bool, bool) representing (is_public_changed, is_final_changed) + pub fn build_keywords( + &mut self, + node: Node, + content: &str, + old_class_is_final: Option, + class_is_final: Option, + ) -> (bool, bool) { + let query_str = r#" + [(property_keyword) @keyword + (return_type (typename (identifier) @returntype )) + ]"#; + let language: &TsLanguage = &LANGUAGE_OBJECTSCRIPT_UDL.into(); + let mut is_final_changed = false; + let mut privacy_changed = false; + if let Ok(query) = Query::new(language, query_str) { + let mut cursor = QueryCursor::new(); + let mut iter = cursor.matches(&query, node, content.as_bytes()); + let keyword_idx = query.capture_index_for_name("keyword"); + let returntype_idx = query.capture_index_for_name("returntype"); + let old_is_public = self.is_public.clone(); + let old_is_final = self.is_final.clone(); + let mut return_type_parameters = Vec::new(); + let mut saw_first_return_type = false; + let mut return_type_id = None; + while let Some(query_match) = iter.next() { + let mut i = 0; + while i < query_match.captures.len() { + let capture = &query_match.captures[i]; + if keyword_idx == Some(capture.index) { + if let Some(keyword_str) = + get_string_at_byte_range(content, capture.node.byte_range()) + { + let (not, keyword_name, _) = + get_keyword_and_value(keyword_str.as_str()); + if keyword_name == "final" { + if not { + self.is_final = Some(false); + } else { + self.is_final = Some(true); + } + } else if keyword_name == "private" { + if not { + self.is_public = true; + } else { + self.is_public = false; + } + } else if keyword_name == "required" { + if not { + self.required = true; + } else { + self.required = false; + } + } else if keyword_name == "multidimensional" { + if not { + self.multidimensional = true; + } else { + self.multidimensional = false; + } + } + } + i += 1; + continue; + } else if returntype_idx == Some(capture.index) { + let return_type_node = capture.node; + let Some(typename) = + get_string_at_byte_range(content, return_type_node.byte_range()) + else { + i += 1; + continue; + }; + if !saw_first_return_type { + return_type_id = Some(find_return_type(typename)); + saw_first_return_type = true; + } else { + return_type_parameters.push(typename); + } + i += 1; + continue; + } + } + } + if let Some(typename_id) = return_type_id { + let typename = TypeName { + ret_type: typename_id, + parameters: return_type_parameters, + }; + self.return_type = Some(typename); + } + let old_final_keyword_res = old_is_final.unwrap_or(old_class_is_final.unwrap_or(false)); + let new_final_keyword = self.is_final.unwrap_or(class_is_final.unwrap_or(false)); + if old_final_keyword_res != new_final_keyword { + is_final_changed = true; + } + if old_is_public != self.is_public { + privacy_changed = true; + } + } + (is_final_changed, privacy_changed) + } +} diff --git a/objectscript-lsp/crates/objectscript-core/src/refactor.rs b/objectscript-lsp/crates/objectscript-core/src/refactor.rs index f7519bf..2bdb1ac 100644 --- a/objectscript-lsp/crates/objectscript-core/src/refactor.rs +++ b/objectscript-lsp/crates/objectscript-core/src/refactor.rs @@ -2,12 +2,202 @@ 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 crate::parse_structures::{FileType, MethodType, OldStatement}; +use crate::scope_tree::ScopeTree; +use std::collections::{HashMap, HashSet}; use std::ops::Range; -use tree_sitter::{InputEdit, Language, Node, Parser, Query, QueryCursor, StreamingIterator}; +use std::sync::OnceLock; +use tree_sitter::{InputEdit, Language, Node, Parser, Query, QueryCursor, StreamingIterator, Tree}; use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; +use tree_sitter_objectscript_playground::LANGUAGE_OBJECTSCRIPT; use tree_sitter_objectscript_routine::LANGUAGE_OBJECTSCRIPT_ROUTINE; -fn update_tree_and_content( +const UNREACHABLE_IF_QUERY: &str = + "(command_if (keyword_old_if) (expression)? @condition (statement)? @statement) @command_if"; +const UNREACHABLE_ELSE_QUERY: &str = + "(command_else (keyword_oldelse) (statement)? @statement) @command"; +const UNREACHABLE_FOR_QUERY: &str = + "(command_for (keyword_for) (for_parameter)? @param (statement)? @statement ) @command"; +const UNREACHABLE_OLD_FOR_QUERY: &str = + "(command_for (keyword_old_for) (for_parameter)? @param (statement)? @statement ) @command"; +const OLD_IF_ELSE_QUERY: &str = r#"(_ + (statement (command_if (keyword_old_if)) @command_if) + . + (statement (command_else) @command_else) +)"#; +const OLD_IF_QUERY: &str = "(command_if (keyword_old_if)) @command"; +const OLD_ELSE_QUERY: &str = "(command_else (keyword_oldelse)) @command_else"; +const OLD_FOR_QUERY: &str = "(command_for (keyword_old_for)) @command"; +const OLD_DO_QUERY: &str = "(command_do (keyword_do_old)) @command"; + +#[derive(Clone, Copy)] +pub enum RefactorGrammar { + Udl, + Routine, + ObjectScript, +} + +impl RefactorGrammar { + fn language(self) -> Language { + match self { + RefactorGrammar::Udl => LANGUAGE_OBJECTSCRIPT_UDL.into(), + RefactorGrammar::Routine => LANGUAGE_OBJECTSCRIPT_ROUTINE.into(), + RefactorGrammar::ObjectScript => LANGUAGE_OBJECTSCRIPT.into(), + } + } +} + +pub fn refactor_grammar_for_file_type(file_type: FileType) -> RefactorGrammar { + match file_type { + FileType::Routine => RefactorGrammar::Routine, + FileType::Cls => RefactorGrammar::Udl, + FileType::Xml => RefactorGrammar::ObjectScript, + } +} + +fn cached_query( + query: &'static OnceLock>, + language: Language, + source: &str, +) -> Option<&'static Query> { + query + .get_or_init(|| Query::new(&language, source).ok()) + .as_ref() +} + +fn cached_query_for_grammar( + grammar: RefactorGrammar, + udl_query: &'static OnceLock>, + routine_query: &'static OnceLock>, + objectscript_query: &'static OnceLock>, + source: &str, +) -> Option<&'static Query> { + match grammar { + RefactorGrammar::Udl => cached_query(udl_query, grammar.language(), source), + RefactorGrammar::Routine => cached_query(routine_query, grammar.language(), source), + RefactorGrammar::ObjectScript => { + cached_query(objectscript_query, grammar.language(), source) + } + } +} + +fn unreachable_if_query(grammar: RefactorGrammar) -> Option<&'static Query> { + static UDL_QUERY: OnceLock> = OnceLock::new(); + static ROUTINE_QUERY: OnceLock> = OnceLock::new(); + static OBJECTSCRIPT_QUERY: OnceLock> = OnceLock::new(); + cached_query_for_grammar( + grammar, + &UDL_QUERY, + &ROUTINE_QUERY, + &OBJECTSCRIPT_QUERY, + UNREACHABLE_IF_QUERY, + ) +} + +fn unreachable_else_query(grammar: RefactorGrammar) -> Option<&'static Query> { + static UDL_QUERY: OnceLock> = OnceLock::new(); + static ROUTINE_QUERY: OnceLock> = OnceLock::new(); + static OBJECTSCRIPT_QUERY: OnceLock> = OnceLock::new(); + cached_query_for_grammar( + grammar, + &UDL_QUERY, + &ROUTINE_QUERY, + &OBJECTSCRIPT_QUERY, + UNREACHABLE_ELSE_QUERY, + ) +} + +fn unreachable_for_query(grammar: RefactorGrammar) -> Option<&'static Query> { + static UDL_QUERY: OnceLock> = OnceLock::new(); + static ROUTINE_QUERY: OnceLock> = OnceLock::new(); + static OBJECTSCRIPT_QUERY: OnceLock> = OnceLock::new(); + cached_query_for_grammar( + grammar, + &UDL_QUERY, + &ROUTINE_QUERY, + &OBJECTSCRIPT_QUERY, + UNREACHABLE_FOR_QUERY, + ) +} + +fn unreachable_old_for_query(grammar: RefactorGrammar) -> Option<&'static Query> { + static UDL_QUERY: OnceLock> = OnceLock::new(); + static ROUTINE_QUERY: OnceLock> = OnceLock::new(); + static OBJECTSCRIPT_QUERY: OnceLock> = OnceLock::new(); + cached_query_for_grammar( + grammar, + &UDL_QUERY, + &ROUTINE_QUERY, + &OBJECTSCRIPT_QUERY, + UNREACHABLE_OLD_FOR_QUERY, + ) +} + +fn old_if_else_query(grammar: RefactorGrammar) -> Option<&'static Query> { + static UDL_QUERY: OnceLock> = OnceLock::new(); + static ROUTINE_QUERY: OnceLock> = OnceLock::new(); + static OBJECTSCRIPT_QUERY: OnceLock> = OnceLock::new(); + cached_query_for_grammar( + grammar, + &UDL_QUERY, + &ROUTINE_QUERY, + &OBJECTSCRIPT_QUERY, + OLD_IF_ELSE_QUERY, + ) +} + +fn old_if_query(grammar: RefactorGrammar) -> Option<&'static Query> { + static UDL_QUERY: OnceLock> = OnceLock::new(); + static ROUTINE_QUERY: OnceLock> = OnceLock::new(); + static OBJECTSCRIPT_QUERY: OnceLock> = OnceLock::new(); + cached_query_for_grammar( + grammar, + &UDL_QUERY, + &ROUTINE_QUERY, + &OBJECTSCRIPT_QUERY, + OLD_IF_QUERY, + ) +} + +fn old_else_query(grammar: RefactorGrammar) -> Option<&'static Query> { + static UDL_QUERY: OnceLock> = OnceLock::new(); + static ROUTINE_QUERY: OnceLock> = OnceLock::new(); + static OBJECTSCRIPT_QUERY: OnceLock> = OnceLock::new(); + cached_query_for_grammar( + grammar, + &UDL_QUERY, + &ROUTINE_QUERY, + &OBJECTSCRIPT_QUERY, + OLD_ELSE_QUERY, + ) +} + +fn old_for_query(grammar: RefactorGrammar) -> Option<&'static Query> { + static UDL_QUERY: OnceLock> = OnceLock::new(); + static ROUTINE_QUERY: OnceLock> = OnceLock::new(); + static OBJECTSCRIPT_QUERY: OnceLock> = OnceLock::new(); + cached_query_for_grammar( + grammar, + &UDL_QUERY, + &ROUTINE_QUERY, + &OBJECTSCRIPT_QUERY, + OLD_FOR_QUERY, + ) +} + +pub fn old_do_query(grammar: RefactorGrammar) -> Option<&'static Query> { + static UDL_QUERY: OnceLock> = OnceLock::new(); + static ROUTINE_QUERY: OnceLock> = OnceLock::new(); + static OBJECTSCRIPT_QUERY: OnceLock> = OnceLock::new(); + cached_query_for_grammar( + grammar, + &UDL_QUERY, + &ROUTINE_QUERY, + &OBJECTSCRIPT_QUERY, + OLD_DO_QUERY, + ) +} + +pub fn update_tree_and_content( tree: &mut tree_sitter::Tree, content: &mut String, old_range: tree_sitter::Range, @@ -43,28 +233,25 @@ pub fn create_parser(language: &Language) -> Option { fn remove_unreachable_statements( content: &mut String, tree: &mut tree_sitter::Tree, - language: &Language, - query_str: &str, + query: &Query, 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()); - } + 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)); + } + 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; - } - }; + 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( @@ -98,391 +285,365 @@ fn add_comment_to_string( // in routines, for each file, store the conditionals fn remove_unreachable_conditionals( content: &str, - language: &Language, + grammar: RefactorGrammar, parser: &mut Parser, -) -> Option<(tree_sitter::Tree, String)> { + initial_tree: tree_sitter::Tree, +) -> (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 mut tree = initial_tree; + if let Some(query) = unreachable_if_query(grammar) { + remove_unreachable_statements(&mut updated_string, &mut tree, query, parser); + } - let query_str = "(command_else (keyword_oldelse) (statement)? @statement) @command"; - remove_unreachable_statements(&mut updated_string, &mut tree, language, query_str, parser); + if let Some(query) = unreachable_else_query(grammar) { + remove_unreachable_statements(&mut updated_string, &mut tree, query, parser); + } // first remove unreachable if statements - Some((tree, updated_string)) + (tree, updated_string) } fn remove_unreachable_for_statements( content: &str, - language: &Language, + grammar: RefactorGrammar, parser: &mut Parser, -) -> Option<(tree_sitter::Tree, String)> { + initial_tree: tree_sitter::Tree, +) -> (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)) + let mut tree = initial_tree; + if let Some(query) = unreachable_for_query(grammar) { + remove_unreachable_statements(&mut updated_string, &mut tree, query, parser); + } + + if let Some(query) = unreachable_old_for_query(grammar) { + remove_unreachable_statements(&mut updated_string, &mut tree, query, parser); + } + (tree, updated_string) } fn refactor_if_else_statements( tree: &mut tree_sitter::Tree, updated_string: &mut String, - language: &Language, - query_str: &str, + query: &Query, ) -> 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( + 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, - 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(), + &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(); + 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( + } + 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, - 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(), + &mut replacement_string, + false, ); - 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(), - ) + } + 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, - ); - } + 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 { + 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()); + true } fn refactor_old_for_statements( tree: &mut tree_sitter::Tree, updated_string: &mut String, - language: &Language, - query_str: &str, + query: &Query, ) -> 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( + 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, - 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(), + &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, - ); - } + 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 { + 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()); + true } fn refactor_old_if_statements( tree: &mut tree_sitter::Tree, updated_string: &mut String, - language: &Language, - query_str: &str, + query: &Query, ) -> 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"); + 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 { - 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(); - } + 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( + } + 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, - 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(), + &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, - ); - } + 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 { + 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()); + true } /// Extracts end byte/point and comment presence flags from an OldStatement. @@ -506,77 +667,71 @@ pub fn check_statement_fields( fn refactor_old_else_statements( tree: &mut tree_sitter::Tree, updated_string: &mut String, - language: &Language, - query_str: &str, + query: &Query, ) -> 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( + 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("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, - 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(), + &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 { + 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()); + true } /// 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 { +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; @@ -590,7 +745,6 @@ pub fn build_old_statement_struct(node: &Node, content: &str) -> Option Option= depth { - let range = std::ops::Range { - start: child.start_byte(), - end: child.end_byte(), - }; - statement_ranges.push(range); - } + statement_ranges.push(range); } "argumentless_inline_comment" => { comment_range = Some(child.range()); @@ -731,7 +879,7 @@ pub fn build_old_statement_struct(node: &Node, content: &str) -> Option 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 let Some(query) = old_if_else_query(grammar) { + loop { + let changed = refactor_if_else_statements(tree, updated_string, query); + if let Some(new_tree) = parser.parse(updated_string.as_str(), Some(tree)) { + *tree = new_tree; + } else { + break; + } - if !changed { - break; + 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 let Some(query) = old_if_query(grammar) { + loop { + let changed = refactor_old_if_statements(tree, updated_string, query); + if let Some(new_tree) = parser.parse(updated_string.as_str(), Some(tree)) { + *tree = new_tree; + } else { + break; + } - if !changed { - break; + 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; + if let Some(query) = old_else_query(grammar) { + loop { + let changed = refactor_old_else_statements(tree, updated_string, query); + if let Some(new_tree) = parser.parse(updated_string.as_str(), Some(tree)) { + *tree = new_tree; + } else { + break; + } + + if !changed { + break; + } } } eprintln!("Finished old else refactoring"); @@ -789,7 +945,7 @@ fn build_replacement_string_block( newline: &str, statements: &[String], ) -> String { - let statement_indent = format!("{base_indent} "); + let statement_indent = child_block_indent(base_indent); let mut new_str = String::new(); new_str.push_str(format!(" {{{newline}").as_str()); new_str @@ -798,139 +954,72 @@ fn build_replacement_string_block( new_str } -fn refactor_legacy_for_statements( +fn child_block_indent(base_indent: &str) -> String { + if !base_indent.is_empty() && base_indent.chars().all(|ch| ch == '\t') { + return format!("{base_indent}\t"); + } + + let base_width = base_indent + .chars() + .map(|ch| if ch == '\t' { 4 } else { 1 }) + .sum::(); + + format!("{base_indent}{}", " ".repeat(4 - (base_width % 4))) +} + +fn refactor_legacy_for_statement_to_block( tree: &mut tree_sitter::Tree, updated_string: &mut String, - language: &Language, + grammar: RefactorGrammar, 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 let Some(query) = old_for_query(grammar) { + loop { + let changed = refactor_old_for_statements(tree, updated_string, query); + if let Some(new_tree) = parser.parse(updated_string.as_str(), Some(tree)) { + *tree = new_tree; + } else { + break; + } - if !changed { - break; + 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(); - }; +pub fn refactor_for_statements( + content: &str, + file_type: FileType, + initial_tree: Tree, + parser: &mut Parser, +) -> (String, Tree) { + let grammar = refactor_grammar_for_file_type(file_type); - 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 + let (mut updated_tree, mut updated_string) = + remove_unreachable_for_statements(content, grammar, parser, initial_tree); + refactor_legacy_for_statement_to_block(&mut updated_tree, &mut updated_string, grammar, parser); + (updated_string, updated_tree) } /// 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(); - }; +pub fn refactor_conditionals_in_document( + content: &str, + file_type: FileType, + initial_tree: tree_sitter::Tree, + parser: &mut Parser, +) -> (String, Tree) { + let grammar = refactor_grammar_for_file_type(file_type); // 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(); - }; + let (mut updated_tree, mut updated_string) = + remove_unreachable_conditionals(content, grammar, parser, initial_tree); // 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 + refactor_old_conditional_command(&mut updated_tree, &mut updated_string, grammar, parser); + (updated_string, updated_tree) } fn generated_subroutine_base_name(subroutine_name: &str) -> &str { @@ -947,14 +1036,14 @@ fn generated_subroutine_base_name(subroutine_name: &str) -> &str { fn generate_subroutine_name( subroutine_name: &str, mut dot_depth: usize, - routine_members: &mut Vec, + routine_members: &mut HashSet, ) -> 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()); + routine_members.insert(candidate.clone()); return candidate; } dot_depth += 1; @@ -979,7 +1068,80 @@ fn line_starts_routine_member(line: &str) -> bool { first.is_ascii_alphabetic() || first == '%' || first == '$' } -fn has_routine_member_between(content: &str, start_byte: usize, end_byte: usize) -> bool { +fn routine_member_name_from_line(line: &str) -> Option { + if !line_starts_routine_member(line) { + return None; + } + + let token = line.split_whitespace().next()?; + let token = token + .split_once('(') + .map(|(name, _)| name) + .unwrap_or(token) + .trim_end_matches(':'); + + (!token.is_empty()).then(|| token.to_string()) +} + +fn routine_member_info_for_node( + content: &str, + node: &Node, +) -> Option<(String, tree_sitter::Range)> { + let node_start = node.start_byte(); + let mut cursor = 0; + let mut member = None; + + while cursor < content.len() { + let line_end = line_end_before_newline(content, cursor, content.len()); + if cursor > node_start { + break; + } + + let line = content + .get(cursor..line_end) + .unwrap_or("") + .trim_end_matches('\r'); + if let Some(name) = routine_member_name_from_line(line) { + member = Some((name, cursor)); + } + + cursor = match next_line_start(content, cursor, content.len()) { + Some(next) => next, + None => break, + }; + } + + let (name, member_start) = member?; + let mut member_end = content.len(); + let mut cursor = next_line_start(content, member_start, content.len()).unwrap_or(content.len()); + while cursor < content.len() { + let line_end = line_end_before_newline(content, cursor, content.len()); + let line = content + .get(cursor..line_end) + .unwrap_or("") + .trim_end_matches('\r'); + if line_starts_routine_member(line) { + member_end = cursor; + break; + } + + cursor = match next_line_start(content, cursor, content.len()) { + Some(next) => next, + None => break, + }; + } + + let range = tree_sitter::Range { + start_byte: member_start, + end_byte: member_end, + start_point: point_at_byte(content, member_start), + end_point: point_at_byte(content, member_end), + }; + + Some((name, range)) +} + +pub 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 { @@ -1010,7 +1172,7 @@ fn line_is_comment_or_blank(line: &str) -> bool { || trimmed.starts_with("/*") } -fn byte_after_trailing_comments(content: &str, start_byte: usize) -> usize { +pub 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(""); @@ -1079,11 +1241,6 @@ fn changes_test_variable(content: &str, node: &Node) -> bool { || 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 @@ -1092,7 +1249,7 @@ fn node_has_child_kind(node: Node, kind: &str) -> bool { has_child } -fn is_old_do_with_dotted_body(content: &str, node: Node) -> bool { +pub 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() @@ -1106,7 +1263,7 @@ fn line_end_before_newline(content: &str, start_byte: usize, max_end_byte: usize .unwrap_or(max_end_byte) } -fn point_at_byte(content: &str, byte_index: usize) -> tree_sitter::Point { +pub 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(); @@ -1147,6 +1304,29 @@ pub fn count_leading_dots_in_line(line: &str) -> usize { count } +pub fn strip_leading_dots_from_line(line: &str) -> String { + let bytes = line.as_bytes(); + let mut cursor = 0; + while matches!(bytes.get(cursor), Some(b' ' | b'\t')) { + cursor += 1; + } + + loop { + if !matches!(bytes.get(cursor), Some(b'.')) { + break; + } + cursor += 1; + while matches!(bytes.get(cursor), Some(b' ' | b'\t')) { + cursor += 1; + } + } + + line.get(cursor..) + .unwrap_or("") + .trim_end_matches('\r') + .to_string() +} + fn direct_dotted_body_depth(content: &str, range: tree_sitter::Range) -> Option { let command_line_start = content .get(..range.start_byte) @@ -1204,7 +1384,7 @@ fn dotted_body_line_ranges( ranges } -fn dotted_body_replacement_end(content: &str, range: tree_sitter::Range) -> Option { +pub 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; @@ -1217,7 +1397,7 @@ fn dotted_body_replacement_end(content: &str, range: tree_sitter::Range) -> Opti ) } -fn strip_dotted_prefix(line: &str, dot_depth: usize) -> Option<(String, usize)> { +fn strip_dotted_prefix(line: &str, dot_depth: usize) -> Option { let bytes = line.as_bytes(); let mut cursor = 0; while matches!(bytes.get(cursor), Some(b' ' | b'\t')) { @@ -1238,29 +1418,35 @@ fn strip_dotted_prefix(line: &str, dot_depth: usize) -> Option<(String, usize)> } } - Some(( + 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; +pub fn build_new_do_call( + content: &str, + sub_name: &str, + has_generated_subroutine: bool, + statement_struct: &OldStatement, +) -> String { + let mut new_do_call = if has_generated_subroutine { + format!("do {sub_name}") + } else { + String::new() }; - 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; + for range in &statement_struct.statements_after { + if let Some(statement) = get_string_at_byte_range(content, range.clone()) { + if new_do_call.is_empty() { + new_do_call.push_str(format!("{statement}").as_str()); + } else { + new_do_call.push_str(format!(" {statement}").as_str()); + } }; - new_do_call.push_str(format!(" {statement}").as_str()); } - Some(new_do_call) + new_do_call } fn line_starts_with_quit_or_return(line: &str) -> bool { @@ -1304,41 +1490,43 @@ fn brace_counts(line: &str) -> (usize, usize) { }) } -fn build_generated_dotted_do( +/// Returns generated subroutine name and text +pub fn build_generated_subroutine( content: &str, - command_do: Node, - outer_subroutine_name: &str, - routine_members: &mut Vec, + command_do: &Node, + routine_members: &mut HashSet, newline: &str, -) -> Option { + special_dotted_statement_tag: bool, + outer_subroutine_name: &str, + is_rtn: bool, +) -> Option<(String, String)> { 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) { + if changes_test_variable(content, command_do) { body.push_str(format!("{base_indent}set temp=$TEST{newline}").as_str()); } + 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; + } 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 { + let Some(stripped_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 line = normalize_generated_dotted_line(stripped_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); @@ -1347,153 +1535,196 @@ fn build_generated_dotted_do( } 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) { + 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} Private{newline}{body}"); - - Some(GeneratedDoSubroutine { - name: sub_name, - text, - }) + let text; + if is_rtn { + if special_dotted_statement_tag { + text = format!("{newline}{sub_name}{newline}{body}"); + } else { + text = format!("{newline}{sub_name} Private{newline}{body}"); + } + } else { + if special_dotted_statement_tag { + text = format!( + "{newline}{base_indent}ClassMethod {sub_name}() [ProcedureBlock = 0] {{{newline}{body}{newline}}}" + ); + } else { + text = format!( + "{newline}{base_indent}ClassMethod {sub_name}() [ProcedureBlock = 0, Private] {{{newline}{body}{newline}}}" + ); + } + } + Some((sub_name.to_string(), 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, + query: &Query, + routine_members: &mut HashSet, + current_class_methods: &HashMap, + scope_tree: &ScopeTree, + is_rtn: bool, ) -> 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 mut nodes = Vec::new(); + let mut special_dotted_statement_tag = false; + 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() + .rev() + .find(|node| is_old_do_with_dotted_body(updated_string.as_str(), *node)) + else { + return false; + }; + let Some(statement_struct) = build_old_statement_struct(&command_do, updated_string.as_str()) + else { + eprintln!("Error: Failed to build do command struct"); + return false; + }; + // it should be fine to just reference the same scope tree and class method ranges, + // since i am doing this bottom up - let Some(associated_parent) = command_do.parent() else { - eprintln!("Couldn't get parent node for do command"); + let routine_member_info = if is_rtn { + let Some(info) = routine_member_info_for_node(updated_string.as_str(), &command_do) else { + eprintln!( + "Error: couldn't find routine member info, aborting (refactor_smallest_dotted_do)" + ); return false; }; - - let Some((outer_subroutine_name, sub_last_statement_range)) = - find_do_statement_subroutine(updated_string.as_str(), &associated_parent) - else { + Some(info) + } else { + None + }; + let method_name = if let Some((name, _)) = &routine_member_info { + name.clone() + } else { + let Some(method_name) = scope_tree.get_method_name(command_do.start_position()) else { eprintln!( - "Couldn't find do statement subroutine {:?}", - command_do.kind() + "Error: couldn't find associated method, aborting (refactor_smallest_dotted_do)" ); return false; }; - let newline = detect_newline(updated_string); - let Some(generated) = build_generated_dotted_do( + method_name + }; + let Some((method_range, method_type)) = current_class_methods.get(&method_name) else { + eprintln!( + "Error: couldn't find associated method range/type, aborting (refactor_smallest_dotted_do)" + ); + return false; + }; + if matches!(method_type, MethodType::DottedSubroutine(_)) { + special_dotted_statement_tag = true; + } + let insertion_boundary_range = routine_member_info + .as_ref() + .map(|(_, range)| range) + .unwrap_or(method_range); + let newline = detect_newline(updated_string); + let Some((generated_name, generated_text)) = build_generated_subroutine( + updated_string.as_str(), + &command_do, + routine_members, + newline, + special_dotted_statement_tag, + method_name.as_str(), + is_rtn, + ) else { + return false; + }; + let mut new_do_call = build_new_do_call( + updated_string.as_str(), + generated_name.as_str(), + true, + &statement_struct, + ); + 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); + } + } + if insertion_boundary_range.end_byte < old_do_range.end_byte { + old_do_range.end_byte = insertion_boundary_range.end_byte; + old_do_range.end_point = insertion_boundary_range.end_point; + } + + 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(), - command_do, - outer_subroutine_name.as_str(), - routine_members, - newline, + comment_range.start_byte..comment_range.end_byte, ) else { + eprintln!("Failed to get comment after dotted do"); 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"); + 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; }; - 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( + 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 is_rtn + && has_routine_member_between( updated_string.as_str(), old_do_range.end_byte, - sub_last_statement_range.end_byte, + insertion_boundary_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; + byte_after_trailing_comments(updated_string.as_str(), old_do_range.end_byte) } else { - return false; - } + byte_after_trailing_comments(updated_string.as_str(), insertion_boundary_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()); + true } /// 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 { +pub 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); @@ -1545,39 +1776,47 @@ fn refactor_spacing_for_subroutines(root: Node, content: &mut String) -> Option< 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(); +/// Extracts dotted `do` bodies from a standalone routine string. +pub fn refactor_legacy_do_statements( + content: &str, + file_type: FileType, + tree: Tree, + scope_tree: &ScopeTree, + parser: &mut Parser, + routine_members: &mut HashSet, + current_class_methods: &HashMap, +) -> (String, Tree) { + let grammar = refactor_grammar_for_file_type(file_type); + let is_rtn = if file_type == FileType::Routine { + true + } else { + false }; - let mut curr_routine_members = routine_members(&tree.root_node(), content); - + let mut tree = tree.clone(); 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; + if let Some(query) = old_do_query(grammar) { + loop { + let changed = refactor_smallest_dotted_do( + &mut tree, + &mut updated_string, + query, + routine_members, + current_class_methods, + scope_tree, + is_rtn, + ); + if let Some(new_tree) = parser.parse(updated_string.as_str(), Some(&tree)) { + tree = new_tree; + } else { + break; + } + if !changed { + break; + } + at_least_one_change = true; } - at_least_one_change = true; } if at_least_one_change { if let Some(replacement_str) = @@ -1588,100 +1827,5 @@ pub fn refactor_legacy_do_statements(content: &str) -> String { }; } - 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; + (updated_string, tree) } diff --git a/objectscript-lsp/crates/objectscript-core/src/scope_structures.rs b/objectscript-lsp/crates/objectscript-core/src/scope_structures.rs index 8dd1307..c5cd97a 100644 --- a/objectscript-lsp/crates/objectscript-core/src/scope_structures.rs +++ b/objectscript-lsp/crates/objectscript-core/src/scope_structures.rs @@ -17,19 +17,30 @@ pub struct VariableGlobalSymbol { pub var_dependencies: Vec, } -/// A method definition symbol for a public method. +/// A parameter definition symbol for a parameter. #[derive(Clone, Debug)] -pub struct MethodGlobalSymbol { - /// Method Name +pub struct ParameterSymbol { + /// parameter Name pub name: String, - /// Document URl containing the method definition. + /// Document URl containing the parameter definition. pub url: Url, - /// Source range of the method definition. + /// Source range of the parameter definition. + pub location: Range, + /// Ranges of references/uses associated with this symbol. + pub references: Vec, +} + +/// A property definition symbol for a property. +#[derive(Clone, Debug)] +pub struct PropertySymbol { + /// Property Name + pub name: String, + /// Document URl containing the property definition. + pub url: Url, + /// Source range of the property 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). @@ -56,7 +67,7 @@ pub struct VariableSymbol { pub var_dependencies: Vec, } -/// A Private Method Symbol +/// A method definition symbol for an ObjectScript method. #[derive(Clone, Debug)] pub struct MethodSymbol { /// Method name @@ -67,6 +78,6 @@ pub struct MethodSymbol { 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, + /// Document URl containing the method definition. + pub url: Url, } diff --git a/objectscript-lsp/crates/objectscript-core/src/scope_tree.rs b/objectscript-lsp/crates/objectscript-core/src/scope_tree.rs index 8be8a29..4b3f53e 100644 --- a/objectscript-lsp/crates/objectscript-core/src/scope_tree.rs +++ b/objectscript-lsp/crates/objectscript-core/src/scope_tree.rs @@ -1,9 +1,9 @@ use crate::common::{generic_exit_statements, point_in_range}; -use crate::parse_structures::{ClassId, MethodId, MethodRef, VariableRef}; +use crate::parse_structures::{ClassId, MethodRef, PropertyRef, VariableRef}; use crate::scope_structures::*; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use tower_lsp::lsp_types::Url; use tree_sitter::{Point, Range}; - /// A lexical scope within a document. #[derive(Clone, Debug)] pub struct Scope { @@ -23,10 +23,18 @@ pub struct Scope { pub private_variable_defs: HashMap>, /// Optional: Name of method that this scope is a part of pub method: Option, + /// True if method is a part of routine (no quit before method), False otherwise. + pub in_routine_statements: bool, } impl Scope { /// Create a new scope node with the given bounds and optional parent. - fn new(start: Point, end: Point, parent: Option, method_name: Option) -> Self { + fn new( + start: Point, + end: Point, + parent: Option, + method_name: Option, + in_routine_statements: bool, + ) -> Self { Self { start, end, @@ -36,9 +44,16 @@ impl Scope { public_var_defs: HashMap::new(), // HashMap var name -> GlobalSymbol private_variable_defs: HashMap::new(), method: method_name, + in_routine_statements, } } + pub fn clear_symbols(&mut self) { + self.public_var_defs.clear(); + self.variable_symbols.clear(); + self.private_variable_defs.clear(); + } + /// 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) @@ -132,8 +147,10 @@ pub struct ScopeTree { 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, + /// Stores MethodRef -> Method Symbol for all private methods in the document. + pub private_method_defs: HashMap, + /// Stores PropertyRef -> Property Symbol for all private properties in the document. + pub private_property_defs: HashMap, /// The Id corresponding to the class definition symbol for this document when this is a class file. pub class_def: Option, } @@ -146,6 +163,7 @@ impl Clone for ScopeTree { root: self.root, next_scope_id: self.next_scope_id, private_method_defs: self.private_method_defs.clone(), + private_property_defs: self.private_property_defs.clone(), class_def: self.class_def, } } @@ -163,6 +181,7 @@ impl ScopeTree { }, None, None, + true, ); let mut scopes = HashMap::new(); scopes.insert(root_id, root_scope); @@ -171,15 +190,66 @@ impl ScopeTree { root: root_id, next_scope_id: 1, private_method_defs: HashMap::new(), + private_property_defs: HashMap::new(), class_def, } } + pub fn get_children_before_scope_id( + &self, + method_call_start: Point, + oref_def_start: Option, + method_children: &Vec, + ) -> Vec { + let mut children_to_check = Vec::new(); + for child_id in method_children { + if let Some(child_scope) = self.scopes.get(child_id) + && (child_scope.start < method_call_start) + { + if let Some(oref_start) = oref_def_start { + if child_scope.start > oref_start { + children_to_check.push(*child_id) + } + } else { + children_to_check.push(*child_id) + } + } + } + children_to_check + } + + /// Given this is a scope within a method scope, + /// climb the parents until you find the method scope + pub fn get_method_scope(&self, point: Point) { + if let Some(scope_id) = self.find_current_scope(point) { + let mut curr_parent = Some(scope_id); + while let Some(parent_id) = curr_parent + && let Some(parent_scope) = self.scopes.get(&parent_id) + { + if parent_id.0 == 0 { + break; + } + + curr_parent = parent_scope.parent; + } + } + } + + /// Look up a private property symbol by name. + /// + /// returns `None` if it does not exist. + pub fn get_private_property_symbol( + &self, + property_ref: &PropertyRef, + ) -> Option<&PropertySymbol> { + self.private_property_defs.get(property_ref) + } + /// 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) + /// returns `None` if it does not exist. + pub fn get_private_method_symbol(&self, method_ref: &MethodRef) -> Option<&MethodSymbol> { + self.private_method_defs.get(method_ref) } /// Add a new child scope to `parent`, returning the new `ScopeId`. @@ -189,10 +259,11 @@ impl ScopeTree { end: Point, parent: ScopeId, method_name: Option, + in_routine_statements: bool, ) -> ScopeId { let scope_id = ScopeId(self.next_scope_id); self.next_scope_id += 1; - let scope = Scope::new(start, end, Some(parent), method_name); + let scope = Scope::new(start, end, Some(parent), method_name, in_routine_statements); // 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); @@ -201,6 +272,95 @@ impl ScopeTree { scope_id } + /// Find the scope that belongs to a given method name. + pub fn find_scope_by_method_name(&self, method_name: &str) -> Option { + self.scopes + .iter() + .find(|(_, scope)| scope.method.as_deref() == Some(method_name)) + .map(|(&id, _)| id) + } + + pub fn reset_method_symbols(&mut self, method_name: &str) { + if let Some(method_scope_id) = self.find_scope_by_method_name(method_name) + && let Some(method_scope) = self.scopes.get_mut(&method_scope_id) + { + method_scope.clear_symbols(); + } + } + + pub fn get_root_children_scopes(&self) -> Vec { + let mut scopes = Vec::new(); + if let Some(root_scope) = self.scopes.get(&self.root) { + for child_id in &root_scope.children { + if let Some(child_scope) = self.scopes.get(child_id) { + scopes.push(child_scope.clone()); + } + } + } + return scopes; + } + + /// Removes a scope and all of its descendant scopes from the tree. + /// Also removes this scope from its parent's children set. + pub fn remove_scope(&mut self, scope_id: ScopeId) { + // Remove from parent's children + if let Some(scope) = self.scopes.get(&scope_id) { + if let Some(parent_id) = scope.parent { + if let Some(parent_scope) = self.scopes.get_mut(&parent_id) { + parent_scope.children.retain(|&id| id != scope_id); + } + } + } + // BFS to collect all descendants + let mut to_remove = vec![scope_id]; + let mut i = 0; + while i < to_remove.len() { + if let Some(scope) = self.scopes.get(&to_remove[i]) { + let children: Vec = scope.children.iter().copied().collect(); + to_remove.extend(children); + } + i += 1; + } + for id in to_remove { + self.scopes.remove(&id); + } + } + + /// Copies the old scope into the new one + /// Returns false if children vec length is different (so we know there's definitely a change) + pub fn copy_method_scope(&mut self, old_scope: &Scope, old_scope_tree: &ScopeTree) -> bool { + let mut new_scope_children = Vec::new(); + if let Some(method_name) = &old_scope.method + && let Some(new_scope_id) = self.find_scope_by_method_name(method_name) + && let Some(new_scope) = self.scopes.get_mut(&new_scope_id) + { + new_scope.variable_symbols = old_scope.variable_symbols.clone(); + new_scope.public_var_defs = old_scope.public_var_defs.clone(); + new_scope.private_variable_defs = old_scope.private_variable_defs.clone(); + new_scope_children = new_scope.children.clone(); + } + + let mut i = 0; + if old_scope.children.len() != new_scope_children.len() { + return false; + } + while i < old_scope.children.len() { + let old_scope_child_id = &old_scope.children[i]; + let new_scope_child_id = &new_scope_children[i]; + if let Some(old_scope_child) = old_scope_tree.scopes.get(old_scope_child_id) + && let Some(new_scope_child) = self.scopes.get_mut(new_scope_child_id) + { + new_scope_child.variable_symbols = old_scope_child.variable_symbols.clone(); + new_scope_child.public_var_defs = old_scope_child.public_var_defs.clone(); + new_scope_child.private_variable_defs = + old_scope_child.private_variable_defs.clone(); + } + i += 1; + } + + true + } + /// Inserts a private variable symbol into the scope containing its start point. pub fn new_variable_symbol( &mut self, @@ -217,18 +377,45 @@ impl ScopeTree { } /// Register a private method definition symbol in this document. - pub fn new_method_symbol(&mut self, name: String, range: Range, method_ref: MethodRef) { + pub fn new_method_symbol( + &mut self, + name: String, + range: Range, + method_ref: MethodRef, + url: Url, + ) { let method_symbol = MethodSymbol { name: name.clone(), location: range, references: Vec::new(), method_dependencies: Vec::new(), - method_ref, + url, }; - self.private_method_defs - .insert(method_ref.id, method_symbol); + self.private_method_defs.insert(method_ref, method_symbol); } + /// Register a private method definition symbol in this document. + pub fn remove_method_symbol(&mut self, method_ref: &MethodRef) { + self.private_method_defs.remove(method_ref); + } + + /// Register a private property definition symbol in this document. + pub fn new_property_symbol( + &mut self, + name: String, + range: Range, + property_ref: PropertyRef, + url: Url, + ) { + let property_symbol = PropertySymbol { + name, + location: range, + references: Vec::new(), + url, + }; + self.private_property_defs + .insert(property_ref, property_symbol); + } /// Get a mutable reference to the innermost scope containing `point`. /// /// Logs a warning and returns `None` if no containing scope is found. @@ -252,7 +439,7 @@ impl ScopeTree { /// Get an immutable reference to the innermost scope containing `point`. /// /// Logs a warning and returns `None` if no containing scope is found. - pub fn get_scope(&self, point: Point) -> Option<&Scope> { + pub fn get_scope(&self, point: Point) -> Option<(ScopeId, &Scope)> { let Some(scope_id) = self.find_current_scope(point) else { eprintln!("Warning: Scope Id not found for Point {:?}", point); return None; @@ -265,15 +452,31 @@ impl ScopeTree { return None; }; - Some(scope) + Some((scope_id, scope)) } - pub fn get_scope_children(&self, scope_id: &ScopeId) -> Vec { + + pub fn get_scope_from_range(&self, start: Point, end: Point) -> Option<(ScopeId, &Scope)> { + let Some(scope_id) = self.find_current_scope_for_range(start, end) else { + eprintln!("Warning: Scope Id not found for Point {:?}", start); + return None; + }; let Some(scope) = self.scopes.get(&scope_id) else { - return Vec::new(); + eprintln!( + "Warning: Scope not found, Scope Id {:?} DNE in scopes hashmap: \n {:?} \n\n", + scope_id, self.scopes + ); + return None; }; - let mut children = Vec::new(); + + Some((scope_id, scope)) + } + pub fn get_scope_children(&self, scope_id: &ScopeId) -> HashSet { + let Some(scope) = self.scopes.get(&scope_id) else { + return HashSet::new(); + }; + let mut children = HashSet::new(); for child_id in &scope.children { - children.push(*child_id); + children.insert(*child_id); children.extend(self.get_scope_children(child_id)) } children @@ -295,7 +498,7 @@ impl ScopeTree { /// Returns the method name associated with the scope containing the given position. pub fn get_method_name(&self, pos: Point) -> Option { - let mut curr_scope = if let Some(scope) = self.get_scope(pos) { + let mut curr_scope = if let Some((_, scope)) = self.get_scope(pos) { scope } else { return None; @@ -323,26 +526,6 @@ impl ScopeTree { scope.get_variable_symbol(variable_index) } - pub fn get_oref_references( - &self, - variable_name: &str, - scope_id: ScopeId, - ) -> Vec<(ScopeId, Vec)> { - let mut var_defs = Vec::new(); - let Some(scope) = self.scopes.get(&scope_id) else { - return Vec::new(); - }; - - let refs = scope.get_variable_references(variable_name); - if !refs.is_empty() { - var_defs.push((scope_id, refs)); - } - for child_scope_id in &scope.children { - var_defs.extend(self.get_oref_references(variable_name, *child_scope_id)); - } - var_defs - } - /// Look up a private variable definition. pub fn get_variable_definition( &self, @@ -383,6 +566,49 @@ impl ScopeTree { } var_defs } + /// Find the innermost scope containing `start` and `end` by descending from the root into matching children. + pub fn find_current_scope_for_range(&self, start: Point, end: 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(start, child_scope.start, child_scope.end) + && point_in_range(end, child_scope.start, child_scope.end) + }); + match child { + Some(&child_id) => current = child_id, + None => { + return Some(current); + } + } + } + } + + /// Returns all scopes that overlap with the given range (start..end). + /// A scope overlaps if its range intersects the query range (not fully contained — any overlap counts). + pub fn find_scopes_in_range(&self, start: Point, end: Point) -> Vec<(ScopeId, &Scope)> { + let mut result = Vec::new(); + let Some(root_scope) = self.scopes.get(&self.root) else { + return result; + }; + for &child_id in &root_scope.children { + let Some(scope) = self.scopes.get(&child_id) else { + continue; + }; + if scope.start < end && scope.end > start { + result.push((child_id, scope)); + } + } + result + } /// Find the innermost scope containing `pos` by descending from the root into matching children. pub fn find_current_scope(&self, pos: Point) -> Option { diff --git a/objectscript-lsp/crates/objectscript-core/src/variable.rs b/objectscript-lsp/crates/objectscript-core/src/variable.rs index 15e6d36..00a1066 100644 --- a/objectscript-lsp/crates/objectscript-core/src/variable.rs +++ b/objectscript-lsp/crates/objectscript-core/src/variable.rs @@ -1,4 +1,4 @@ -use crate::parse_structures::{ReturnType, Variable}; +use crate::parse_structures::{TypeName, Variable}; impl Variable { /// Construct a `Variable` with an optional declared argument type and inferred expression types. @@ -7,7 +7,7 @@ impl Variable { /// types/atoms observed in the RHS/default expression. pub fn new( var_name: String, - arg_type: Option, + arg_type: Option, is_public: bool, is_oref: bool, cls: Option, diff --git a/objectscript-lsp/crates/objectscript-core/src/workspace.rs b/objectscript-lsp/crates/objectscript-core/src/workspace.rs index bc8462e..dd923d0 100644 --- a/objectscript-lsp/crates/objectscript-core/src/workspace.rs +++ b/objectscript-lsp/crates/objectscript-core/src/workspace.rs @@ -1,37 +1,57 @@ 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, + find_class_definition, generic_exit_statements, get_member_name_and_range_from_root, + initial_build_scope_tree, point_to_byte, ts_range_to_lsp_range, }; 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, + Class, ClassId, FileType, MethodRef, MethodType, ParameterRef, PropertyRef, RefactorLevel, + UnresolvedMethodRef, VariableRef, }; use crate::refactor::{ - refactor_conditionals, refactor_for_statements, refactor_legacy_do_statements, + refactor_conditionals_in_document, refactor_for_statements, refactor_legacy_do_statements, }; - -use crate::scope_structures::{MethodGlobalSymbol, ScopeId}; +use crate::scope_structures::ScopeId; use crate::scope_tree::ScopeTree; use parking_lot::{Mutex, RwLock}; -use std::collections::HashMap; +use petgraph::visit::EdgeRef; +use std::collections::{HashMap, HashSet}; 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, -}; +#[cfg(feature = "update-bench")] +use std::sync::atomic::{AtomicUsize, Ordering}; +use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Range as LspRange, Url}; +use tree_sitter::{Parser, Point, Range, Tree}; use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; use tree_sitter_objectscript_routine::LANGUAGE_OBJECTSCRIPT_ROUTINE; use tree_sitter_xml::LANGUAGE_XML; +#[cfg(feature = "update-bench")] +static FULL_UPDATE_DOCUMENT_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[cfg(feature = "update-bench")] +pub fn reset_full_update_document_call_count() { + FULL_UPDATE_DOCUMENT_CALLS.store(0, Ordering::Relaxed); +} + +#[cfg(feature = "update-bench")] +pub fn full_update_document_call_count() -> usize { + FULL_UPDATE_DOCUMENT_CALLS.load(Ordering::Relaxed) +} + +#[cfg(not(feature = "update-bench"))] +pub fn reset_full_update_document_call_count() {} + +#[cfg(not(feature = "update-bench"))] +pub fn full_update_document_call_count() -> usize { + 0 +} + /// Holds Tree-sitter parsers for each supported ObjectScript file grammar. pub struct WorkspaceParsers { /// Parser for `.mac` / `.inc` routine files. @@ -96,9 +116,13 @@ pub struct ProjectData { 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 + /// Maps Class Name -> another hashmap which maps Method Name -> MethodRef for all Methods Accessible from the class. pub method_defs: HashMap>, - /// Maps Var Name -> another hashmap which maps MethodRef -> Vec for that variable. + /// Maps Class Name -> another hashmap which maps Property Name -> PropertyRef for all Properties Accessible from the class. + pub property_defs: HashMap>, + /// Maps Class Name -> another hashmap which maps Parameter Name -> ParameterRef for all Parameters Accessible from the class. + pub parameter_defs: HashMap>, + /// Maps Var Name -> another hashmap which maps MethodRef -> HashMap of ScopeId -> Vec for that variable. pub pub_var_defs: HashMap>>>, /// Holds the OverrideIndex for the workspace. pub override_index: OverrideIndex, @@ -106,6 +130,13 @@ pub struct ProjectData { pub dependent_class_index: Dependents, /// Graph of all calls to methods/procedures/subroutines for each class pub dependency_graph: DependencyGraph, + /// Unresolved Class Name -> Class Id that tried to inherit it + pub unresolved_inheritance_references: HashMap>, + /// (Unresolved ClassName, Unresolved Method Name) -> HashSet<(MethodRef, Range)> representing the place the unresolved reference took place. + pub unresolved_method_references: HashMap<(String, String), HashSet<(MethodRef, Range)>>, + pub inheritance_diagonstics: HashMap>, + pub method_reference_diagnostics: HashMap<(String, String), HashMap>, + pub other_class_diagnostics: HashMap>, } /// Concurrency wrapper for a workspace’s state and parsers. @@ -124,6 +155,22 @@ pub struct ProjectState { } impl ProjectData { + pub fn clear_diagnostics_for_url(&mut self, url: &Url) { + self.other_class_diagnostics.remove(url); + + self.inheritance_diagonstics + .retain(|_, diagnostics_by_url| { + diagnostics_by_url.remove(url); + !diagnostics_by_url.is_empty() + }); + + self.method_reference_diagnostics + .retain(|_, diagnostics_by_url| { + diagnostics_by_url.remove(url); + !diagnostics_by_url.is_empty() + }); + } + /// Return basic immutable snapshot information for a document. /// /// Produces `(file_type, content, version, tree)` for the document at `url`. The text and tree @@ -152,16 +199,31 @@ impl ProjectData { &mut self, url: Url, code: String, - tree: Tree, + tree: &Tree, filetype: FileType, - class_name: Option, + class_name: String, + class_range: Range, 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); + let class_id = if filetype == FileType::Xml { + None + } else { + Some(ClassId(self.global_semantic_model.next_id())) + }; + self.add_document( + url, + code.as_str(), + tree, + filetype, + class_id, + class_name, + version, + class_range, + ); false } @@ -169,7 +231,7 @@ impl ProjectData { /// `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 filetype = { let Some(document) = self.get_document(url) else { eprintln!( "Tried to refactor document {:?}, but it does not exist ", @@ -177,36 +239,132 @@ impl ProjectData { ); return None; }; - (document.file_type.clone(), document.content.clone()) + document.file_type.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()) - } + + let language = match filetype { + FileType::Routine => LANGUAGE_OBJECTSCRIPT_ROUTINE.into(), + FileType::Cls => LANGUAGE_OBJECTSCRIPT_UDL.into(), + FileType::Xml => return None, + }; + let mut parser = Parser::new(); + if parser.set_language(&language).is_err() { + eprintln!("Error: Failed to load refactor grammar"); + return None; + } + + self.refactor_document_with_parser(url, refactor_level, &mut parser) + } + + fn legacy_do_refactor_context( + &self, + document: &Document, + ) -> ( + HashSet, + HashMap, + ) { + let mut current_class_methods = HashMap::new(); + let Some(class_id) = document.class_id else { + return (HashSet::new(), current_class_methods); + }; + let Some(class) = self.global_semantic_model.get_class(&class_id) else { + return (HashSet::new(), current_class_methods); + }; + let routine_members = class.methods.keys().cloned().collect(); + for (method_name, method_ref) in &class.methods { + let Some(method) = self.global_semantic_model.get_method(method_ref) else { + continue; + }; + let range = self + .global_semantic_model + .get_method_symbol(method_ref) + .map(|method_symbol| method_symbol.location) + .or_else(|| { + document + .scope_tree + .get_private_method_symbol(method_ref) + .map(|method_symbol| method_symbol.location) + }); + let Some(range) = range else { + continue; + }; + current_class_methods.insert(method_name.clone(), (range, method.method_type)); + } + + (routine_members, current_class_methods) + } + + fn refactor_document_with_parser( + &self, + url: &Url, + refactor_level: RefactorLevel, + parser: &mut Parser, + ) -> Option { + let Some(document) = self.get_document(url) else { + eprintln!( + "Tried to refactor document {:?}, but it does not exist ", + url.path() + ); + return None; + }; + let (mut routine_members, current_class_methods) = + self.legacy_do_refactor_context(document); + if document.file_type == FileType::Xml { + return None; + } + + let (updated_content, _) = match refactor_level { + RefactorLevel::DoCommands => refactor_legacy_do_statements( + document.content.as_str(), + document.file_type, + document.tree.clone(), + &document.scope_tree, + parser, + &mut routine_members, + ¤t_class_methods, + ), + RefactorLevel::Conditionals => refactor_conditionals_in_document( + document.content.as_str(), + document.file_type, + document.tree.clone(), + parser, + ), + RefactorLevel::ForCommands => refactor_for_statements( + document.content.as_str(), + document.file_type, + document.tree.clone(), + parser, + ), 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()) + let (content_after_do_refactor, tree_after_do_refactor) = + refactor_legacy_do_statements( + document.content.as_str(), + document.file_type, + document.tree.clone(), + &document.scope_tree, + parser, + &mut routine_members, + ¤t_class_methods, + ); + let (content_after_if_refactor, tree_after_if_refactor) = + refactor_conditionals_in_document( + content_after_do_refactor.as_str(), + document.file_type, + tree_after_do_refactor, + parser, + ); + refactor_for_statements( + content_after_if_refactor.as_str(), + document.file_type, + tree_after_if_refactor, + parser, + ) } }; - if content == updated_content.as_str() { + if document.content.as_str() == updated_content.as_str() { None } else { Some(updated_content) @@ -239,200 +397,1385 @@ impl ProjectData { changed } + fn resolve_method_references( + &mut self, + content: &str, + unresolved_method_refs: &HashSet, + method_ref: MethodRef, + class_id: ClassId, + ) { + for unresolved_method_ref in unresolved_method_refs { + if let Some(referenced_method) = self.resolve_accessible_method_ref( + &unresolved_method_ref.class, + &unresolved_method_ref.method, + Some(class_id), + ) { + self.dependency_graph.add_edge( + method_ref, + referenced_method, + unresolved_method_ref.method_call_range, + ); + } else { + self.unresolved_method_references + .entry(( + unresolved_method_ref.class.clone(), + unresolved_method_ref.method.clone(), + )) + .or_insert(HashSet::new()) + .insert((method_ref, unresolved_method_ref.method_call_range)); + if let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(&method_ref.class) + { + let lsp_range = + ts_range_to_lsp_range(content, unresolved_method_ref.method_call_range); + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::WARNING), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "Method referenced has either not yet been indexed or does not exist" + ), + related_information: None, + tags: None, + data: None, + }; + self.method_reference_diagnostics + .entry(( + unresolved_method_ref.class.clone(), + unresolved_method_ref.method.clone(), + )) + .or_insert(HashMap::new()) + .insert(cls_sym.url.clone(), diagnostic); + } + } + } + } + + fn resolve_accessible_method_ref( + &self, + class_name: &str, + method_name: &str, + fallback_class_id: Option, + ) -> Option { + self.method_defs + .get(class_name) + .and_then(|methods| methods.get(method_name)) + .copied() + .or_else(|| { + self.override_index + .effective_methods + .get(class_name) + .and_then(|methods| methods.get(method_name)) + .copied() + }) + .or_else(|| { + self.classes + .get(class_name) + .copied() + .or_else(|| { + fallback_class_id.and_then(|class_id| { + self.global_semantic_model + .get_class(&class_id) + .and_then(|class| (class.name == class_name).then_some(class_id)) + }) + }) + .and_then(|class_id| self.global_semantic_model.get_class(&class_id)) + .and_then(|class| class.methods.get(method_name)) + .copied() + }) + } + + fn fully_remove_old_class_members(&mut self, class_id: &ClassId, content: &str) { + let classes: HashSet = self.classes.values().copied().collect(); + let (class_name, inherited_classes) = + if let Some(class) = self.global_semantic_model.get_class(&class_id) { + let class_name = class.name.clone(); + let inherited_classes = class.inherited_classes.clone(); + (class_name, inherited_classes) + } else { + return; + }; + + self.full_reset_class_inheritance(&class_name, *class_id, &inherited_classes); + + if let Some(stale_methods) = self.method_defs.remove(&class_name) { + for (stale_method_name, stale_method_ref) in stale_methods { + if let Some(stale_node_index) = self.dependency_graph.get_node(stale_method_ref) { + let method_caller_refs = self + .dependency_graph + .remove_incoming_calls_to_node(*stale_node_index); + for (method_ref, method_call_range) in &method_caller_refs { + let lsp_range = ts_range_to_lsp_range(content, *method_call_range); + let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(&method_ref.class) + else { + continue; + }; + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::WARNING), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "Method referenced has either not yet been indexed or does not exist" + ), + related_information: None, + tags: None, + data: None, + }; + self.method_reference_diagnostics + .entry((class_name.to_string(), stale_method_name.clone())) + .or_insert(HashMap::new()) + .insert(cls_sym.url.clone(), diagnostic); + } + self.unresolved_method_references + .entry((class_name.to_string(), stale_method_name.clone())) + .or_insert(HashSet::new()) + .extend(method_caller_refs); + } + self.global_semantic_model.remove_method(&stale_method_ref); + } + } + if let Some(stale_properties) = self.property_defs.remove(&class_name) { + for (_, stale_property_ref) in stale_properties { + self.global_semantic_model + .remove_property(&stale_property_ref); + } + } + if let Some(stale_parameters) = self.parameter_defs.remove(&class_name) { + for (_, stale_parameter_ref) in stale_parameters { + self.global_semantic_model + .remove_parameter(&stale_parameter_ref); + } + } + + if let Some(class) = self.global_semantic_model.get_mut_class(class_id) { + class.clear(class_name, false); + } + + self.rebuild_override_index_for_classes_and_apply(&classes); + } + + pub fn full_update_document( + &mut self, + url: Url, + content: &str, + tree: &Tree, + filetype: FileType, + class_id: ClassId, + class_name: String, + version: Option, + class_range: Range, + ) { + #[cfg(feature = "update-bench")] + FULL_UPDATE_DOCUMENT_CALLS.fetch_add(1, Ordering::Relaxed); + self.clear_diagnostics_for_url(&url); + + if filetype == FileType::Xml { + let document = Document::new( + content.to_string(), + tree.clone(), + filetype, + "XML".to_string(), + None, + ScopeTree::new(None), + version, + ); + self.documents.insert(url, document); + return; + } else if filetype == FileType::Routine || filetype == FileType::Cls { + self.fully_remove_old_class_members(&class_id, content); + self.global_semantic_model.remove_class(&class_id); + self.documents.remove(&url); + self.add_document( + url, + content, + tree, + filetype, + Some(class_id), + class_name, + version, + class_range, + ); + } + } + /// Parse and register a new document, initializing semantic + symbol state for `.cls` files. /// - /// For class files (`FileType::Cls`), this: - /// - Extracts the class definition/range + /// For all ObjectScript files, this: + /// - Extracts the class 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, + content: &str, + tree: &Tree, filetype: FileType, - class_name: Option, + class_id: Option, + class_name: String, version: Option, + class_range: Range, ) { + self.clear_diagnostics_for_url(&url); + if filetype == FileType::Xml { - let document = Document::new(code, tree, filetype, None, ScopeTree::new(None), version); + let document = Document::new( + content.to_string(), + tree.clone(), + filetype, + "XML".to_string(), + 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() - ); + let Some(class_id) = class_id else { return; }; - let content = code.as_str(); - let mut local_semantic_model = LocalSemanticModel::new(); + if self.documents.contains_key(&url) { + eprintln!("Error: Document already exists"); + return; + } let is_rtn = if filetype == FileType::Routine { true } else { false }; - let mut class = Class::new(member_name.clone(), is_rtn); + let scope_tree = initial_build_scope_tree(&tree, class_id, content, is_rtn); + let mut document = Document::new( + content.to_string(), + tree.clone(), + filetype, + class_name.clone(), + Some(class_id), + scope_tree, + version, + ); + let local_semantic_model = LocalSemanticModel::new(); + self.global_semantic_model + .new_local_semantic(class_id, local_semantic_model); + let mut class = Class::new(class_name.clone(), is_rtn); + self.classes.insert(class_name.clone(), class_id); 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 + class_name ); return; }; node }; - let cls_range; - if is_rtn { - if let Some(rtn_range) = get_routine_range(tree.root_node()) { - cls_range = rtn_range; + // this is a new class, so some things returned from this function are not applicable + let (_, _, methods, properties, parameters, inherited_classes, _, class_diagnostics) = + class.build_class( + starting_node, + content, + is_rtn, + &class_id, + class_range, + &class_name, + ); + + class.build_imports(tree, content); + + // adds class and class symbol to global semantic model + self.global_semantic_model + .new_class(class, class_id, class_range, url.clone()); + self.other_class_diagnostics + .insert(url.clone(), class_diagnostics); + // inherits is_procedure_block, is_final, language from leftmost inherited class if applicable + self.rebuild_keyword_inheritance_for_class(&class_id); + // NOTE: this must be checked after the keyword inheritance is completed. + let (class_is_final, class_is_procedure_block) = { + if let Some(class) = self.global_semantic_model.get_class(&class_id) { + (class.is_final, class.is_procedure_block) } else { - cls_range = starting_node.range(); + return; } - } 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, + let mut classes_to_recompute_inheritance = HashSet::new(); + self.new_class_inheritance( + &class_name, + class_id, + &inherited_classes, + class_is_final.unwrap_or(false), + &mut classes_to_recompute_inheritance, + &url, ); + classes_to_recompute_inheritance.insert(class_id); + let mut unresolved_orefs = HashMap::new(); // 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(), + for (method_name, (mut method, method_range, method_ref, public_variables_declared)) in + methods + { + self.resolve_unresolved_method( + &(class_name.clone(), method_name.clone()), + method_ref, + ); + self.dependency_graph.get_or_add_node(method_ref); + let method_type = method.method_type.clone(); + let mut variable_info = Vec::new(); + let mut unresolved_method_refs = HashSet::new(); + let mut unresolved_oref_method_refs = HashSet::new(); + match method_type { + MethodType::ClassMethod | MethodType::InstanceMethod => { + if let Some(method_definition_node) = + tree.root_node().named_descendant_for_byte_range( + method_range.start_byte, + method_range.end_byte, + ) + { + ( + _, + _, + variable_info, + unresolved_method_refs, + unresolved_oref_method_refs, + ) = method.rebuild_method( + method_definition_node, + content, + &document.scope_tree, + method_type, + method_range, + public_variables_declared, + class_is_final, + None, + class_is_procedure_block, + &class_name, + ); + } + } + MethodType::Procedure(_) => { + if let Some(method_definition_node) = + tree.root_node().named_descendant_for_byte_range( + method_range.start_byte, + method_range.end_byte, + ) + { + ( + _, + _, + variable_info, + unresolved_method_refs, + unresolved_oref_method_refs, + ) = method.rebuild_method( + method_definition_node, + content, + &document.scope_tree, + method_type, + method_range, + public_variables_declared, + class_is_final, + None, + class_is_procedure_block, + &class_name, + ); + } + } + MethodType::Subroutine(_) + | MethodType::Routine + | MethodType::DottedSubroutine(_) => { + ( + _, + _, + variable_info, + unresolved_method_refs, + unresolved_oref_method_refs, + ) = method.rebuild_method( + tree.root_node(), + content, + &document.scope_tree, + method_type, + method_range, + public_variables_declared, + class_is_final, + None, + class_is_procedure_block, + &class_name, + ); + } + } + for (variable, variable_range, variable_dependencies, variable_scope_id) in + variable_info + { + let variable_name = variable.name.clone(); + let variable_is_public = variable.is_public; + // add it to global semantic model (if public) or local semantic model/scope tree (if private) + // global semantic will add it to local semantic if private + let variable_ref = self.global_semantic_model.new_variable( + variable, method_ref, + variable_scope_id, + variable_dependencies.clone(), + variable_range, + url.clone(), ); - 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 + + // add variable ref and corresponding scope id to method + method + .variables + .entry(variable_name.clone()) + .or_insert(Vec::new()) + .push((variable_ref, variable_scope_id)); + + if variable_is_public { + document.scope_tree.new_public_var_symbol( + variable_name.clone(), + variable_range, + variable_ref, + ); + self.pub_var_defs + .entry(variable_name) + .or_insert(HashMap::new()) + .entry(method_ref) + .or_insert(HashMap::new()) + .entry(variable_scope_id) + .or_insert(Vec::new()) + .push(variable_ref); + } else { + document.scope_tree.new_variable_symbol( + variable_name, + variable_range, + variable_dependencies, + variable_ref, + ); + } + } + self.dependency_graph.get_or_add_node(method_ref); + self.method_defs + .entry(class_name.clone()) + .or_insert_with(HashMap::new) + .insert(method_name.clone(), method_ref); + self.resolve_method_references( + content, + &unresolved_method_refs, + method_ref, + class_id, + ); + unresolved_orefs.insert(method_ref, unresolved_oref_method_refs); + if !method.is_public { + // creates method symbol in scope tree (private) document.scope_tree.new_method_symbol( method_name.clone(), method_range, method_ref, + url.clone(), ); - self.method_defs - .entry(member_name.clone()) - .or_insert_with(HashMap::new) - .insert(method_name.clone(), method_ref); } + // adds method to global semantic model if public, and to local semantic model if private + // also, creates method symbol if public + self.global_semantic_model.new_method( + method, + method_ref, + method_range, + url.clone(), + ); + self.compute_inheritance_override_index_method( + &classes_to_recompute_inheritance, + &HashSet::new(), + method_name, + method_ref, + true, + class_is_final.unwrap_or(false), + ); } - // 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); + for (method_ref, unresolved_oref_methods) in unresolved_orefs { + for (oref_name, oref_method_name, method_call_range, current_method_name) in + unresolved_oref_methods + { + let (resolved, unresolved) = self.resolve_oref_methods( + method_ref, + &oref_name, + &oref_method_name, + method_call_range, + ¤t_method_name, + &document.scope_tree, + ); + for (key, value) in unresolved { + for (method_ref, method_call_range) in &value { + let lsp_range = ts_range_to_lsp_range(content, *method_call_range); + let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(&method_ref.class) + else { + continue; + }; + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::WARNING), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "Method referenced has either not yet been indexed or does not exist" + ), + related_information: None, + tags: None, + data: None, + }; + self.method_reference_diagnostics + .entry(key.clone()) + .or_insert(HashMap::new()) + .insert(cls_sym.url.clone(), diagnostic); + } + self.unresolved_method_references + .entry(key) + .or_insert(HashSet::new()) + .extend(value); + } + for referenced_method_ref in &resolved { + self.dependency_graph.add_edge( + method_ref, + *referenced_method_ref, + method_call_range, + ); + } + } + } + for (property_name, (property, property_range, property_ref)) in properties { + if !property.is_public { + // creates and stores property symbol in scope tree + document.scope_tree.new_property_symbol( + property_name.clone(), + property_range, + property_ref, + url.clone(), + ) + } + // adds property to gsm if public, lsm if private + // if public, creates and stores property symbol in gsm + self.global_semantic_model.new_property( + property, + property_ref, + property_range, + url.clone(), + ); + self.property_defs + .entry(class_name.clone()) + .or_insert_with(HashMap::new) + .insert(property_name.clone(), property_ref); + self.compute_inheritance_override_index_property( + &classes_to_recompute_inheritance, + &HashSet::new(), + property_name, + property_ref, + true, + class_is_final.unwrap_or(false), + ); + } + for (parameter_name, (parameter, parameter_range, parameter_ref)) in parameters { + // creates property symbol and stores it in global semantic model + // also stores parameter in gsm + self.global_semantic_model.new_parameter( + parameter, + parameter_ref, + parameter_range, + url.clone(), + ); + // add property ref to workspace + self.parameter_defs + .entry(class_name.clone()) + .or_insert_with(HashMap::new) + .insert(parameter_name.clone(), parameter_ref); + self.compute_inheritance_override_index_parameter( + &classes_to_recompute_inheritance, + &HashSet::new(), + parameter_name, + parameter_ref, + true, + class_is_final.unwrap_or(false), + ); + } 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( + fn remove_stale_class_from_dependent_classes( &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; + class_id: ClassId, + inherited_classes: &Vec<(String, LspRange)>, + ) -> Vec { + let mut stale_classes = Vec::new(); + for (old_inherited_class, _) in inherited_classes { + if let Some(inherited_class_id) = self.classes.get(old_inherited_class) { + stale_classes.push(*inherited_class_id); + if let Some(inherited_class_dependents) = self + .dependent_class_index + .direct_subclasses + .get_mut(inherited_class_id) + { + inherited_class_dependents.remove(&class_id); + } + self.dependent_class_index + .rebuild_transitive_subclasses(*inherited_class_id); + } + } + stale_classes + } + + /// For each `String` in `inherited_classes` representing the class name, this + /// finds the corresponding `ClassId` and adds `dependent_class_id` and all dependents of `dependent_class_id` to the newly inherited classes. + fn add_dependent_class_to_inherited_class( + &mut self, + dependent_class_id: ClassId, + dependent_document_url: Url, + inherited_classes: &Vec<(String, LspRange)>, + classes_to_recompute_inheritance: &mut HashSet, + ) { + for (inherited_cls_name, inherited_class_ref_range) in inherited_classes { + if let Some(inherited_class_id) = self.classes.get(inherited_cls_name).copied() { + self.dependent_class_index + .direct_subclasses + .entry(inherited_class_id) + .or_insert(HashMap::new()) + .insert(dependent_class_id, inherited_class_ref_range.clone()); + self.dependent_class_index + .dependent_classes + .entry(inherited_class_id) + .or_insert(HashSet::new()) + .insert(dependent_class_id); + let new_class_dependents = self + .dependent_class_index + .dependent_classes + .get(&dependent_class_id) + .cloned() + .unwrap_or_default(); + self.dependent_class_index + .dependent_classes + .entry(inherited_class_id) + .or_insert(HashSet::new()) + .extend(new_class_dependents.clone()); + classes_to_recompute_inheritance.extend(new_class_dependents); + } else { + let diagnostic = Diagnostic { + range: inherited_class_ref_range.clone(), + severity: Some(DiagnosticSeverity::WARNING), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: + "Reference to Class that has either not yet been indexed or does not exist" + .to_string(), + related_information: None, + tags: None, + data: None, + }; + self.unresolved_inheritance_references + .entry(inherited_cls_name.clone()) + .or_insert(Vec::new()) + .push((dependent_class_id, inherited_class_ref_range.clone())); + self.inheritance_diagonstics + .entry(inherited_cls_name.clone()) + .or_insert(HashMap::new()) + .insert(dependent_document_url.clone(), diagnostic); + } + } + } + + fn resolve_unresolved_class( + &mut self, + unresolved_class_name: &str, + unresolved_url: &Url, + classes_to_recompute_inheritance: &mut HashSet, + ) { + // find any current classes that already extend this class + if let Some(classes_already_extending_current_class) = self + .unresolved_inheritance_references + .get(unresolved_class_name) + .cloned() + { + for (dependent_class_id, inherited_ref_range) in classes_already_extending_current_class + { + let inherited_class = + &vec![(unresolved_class_name.to_string(), inherited_ref_range)]; + self.add_dependent_class_to_inherited_class( + dependent_class_id, + unresolved_url.clone(), + inherited_class, + classes_to_recompute_inheritance, + ); + classes_to_recompute_inheritance.insert(dependent_class_id); + } + } + self.unresolved_inheritance_references + .remove(unresolved_class_name); + self.inheritance_diagonstics.remove(unresolved_class_name); + } + + fn resolve_unresolved_method(&mut self, key: &(String, String), method_ref: MethodRef) { + if let Some(unresolved_method_callers) = self.unresolved_method_references.remove(key) { + for method_caller in unresolved_method_callers { + self.dependency_graph + .add_edge(method_caller.0, method_ref, method_caller.1); + } + } + self.method_reference_diagnostics.remove(key); + } + + fn update_class_name_in_workspace(&mut self, old_class_name: &str, new_class_name: &str) { + // remove all pointers to the old class name + self.classes.remove(old_class_name); + self.parameter_defs.remove(old_class_name); + self.property_defs.remove(old_class_name); + // move all method refs stored in old class hash to new class + if let Some(method_hash) = self.method_defs.remove(old_class_name) { + self.method_defs + .insert(new_class_name.to_string(), method_hash); + } + } + + /// Adds the dependent classes to the hashset of `classids` to rebuild in override index. + /// Resolves any previous unresolved inheritance efforts for `class_id`. + fn new_class_inheritance( + &mut self, + new_class_name: &str, + class_id: ClassId, + new_inherited_classes: &Vec<(String, LspRange)>, + new_class_is_final: bool, + classes_to_recompute_inheritance: &mut HashSet, + current_url: &Url, + ) { + if !new_class_is_final { + self.resolve_unresolved_class( + new_class_name, + current_url, + classes_to_recompute_inheritance, + ); + } + self.add_dependent_class_to_inherited_class( + class_id, + current_url.clone(), + new_inherited_classes, + classes_to_recompute_inheritance, + ); + let mut curr_class_hash = HashSet::new(); + curr_class_hash.insert(class_id); + curr_class_hash.extend(classes_to_recompute_inheritance.iter().copied()); + self.rebuild_override_index_for_classes_and_apply(&curr_class_hash); + } + + // in this scenario, the override index should be recomputed for ALL classes everytime, so no need to track which ones + fn full_reset_class_inheritance( + &mut self, + class_name: &str, + class_id: ClassId, + old_inherited_classes: &Vec<(String, LspRange)>, + ) { + if let Some(direct_dependents) = self + .dependent_class_index + .direct_subclasses + .remove(&class_id) + { + self.unresolved_inheritance_references + .entry(class_name.to_string()) + .or_insert(Vec::new()) + .extend(direct_dependents.clone()); + for (class_that_made_ref, lsp_range) in direct_dependents { + if let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(&class_that_made_ref) + { + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::WARNING), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: "Reference to ObjectScript Class that has either not yet been indexed or doesn't exist".to_string(), + related_information: None, + tags: None, + data: None, + }; + self.inheritance_diagonstics + .entry(class_name.to_string()) + .or_insert(HashMap::new()) + .insert(cls_sym.url.clone(), diagnostic); + } + } + } + self.remove_stale_class_from_dependent_classes(class_id, old_inherited_classes); + } + + fn update_class_inheritance( + &mut self, + old_class_name: &str, + new_class_name: &str, + class_id: ClassId, + old_inherited_classes: &Vec<(String, LspRange)>, + new_inherited_classes: &Vec<(String, LspRange)>, + inheritance_changed: bool, + old_class_is_final: bool, + new_class_is_final: bool, + classes_to_recompute_inheritance: &mut HashSet, + url: &Url, + content: &str, + ) { + // (bool) recompute inheritance for ALL class members for the subclasses + // resolve any references from other classes -> the new class name + if !new_class_is_final { + self.resolve_unresolved_class(new_class_name, url, classes_to_recompute_inheritance); + } + let class_name_changed = new_class_name != old_class_name; + let is_final_changed = old_class_is_final != new_class_is_final; + if (class_name_changed || is_final_changed || inheritance_changed) + && let Some(current_dependents) = + self.dependent_class_index.dependent_classes.get(&class_id) + { + classes_to_recompute_inheritance.extend(current_dependents); + + if new_class_is_final || class_name_changed { + // remove all references to the old class + self.dependent_class_index + .dependent_classes + .remove(&class_id); + if let Some(direct_dependents) = self + .dependent_class_index + .direct_subclasses + .remove(&class_id) + { + if new_class_is_final && !class_name_changed { + self.unresolved_inheritance_references + .entry(new_class_name.to_string()) + .or_insert(Vec::new()) + .extend(direct_dependents.clone()); + for (class_that_made_ref, lsp_range) in &direct_dependents { + if let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(class_that_made_ref) + { + let diagnostic = Diagnostic { + range: *lsp_range, + severity: Some(DiagnosticSeverity::WARNING), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: "Reference to ObjectScript Class that has either not yet been indexed or doesn't exist".to_string(), + related_information: None, + tags: None, + data: None, + }; + self.inheritance_diagonstics + .entry(new_class_name.to_string()) + .or_insert(HashMap::new()) + .insert(cls_sym.url.clone(), diagnostic); + } + } + } + if class_name_changed { + for (class_that_made_ref, lsp_range) in &direct_dependents { + if let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(class_that_made_ref) + { + let diagnostic = Diagnostic { + range: *lsp_range, + severity: Some(DiagnosticSeverity::WARNING), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: "Reference to ObjectScript Class that has either not yet been indexed or doesn't exist".to_string(), + related_information: None, + tags: None, + data: None, + }; + self.inheritance_diagonstics + .entry(new_class_name.to_string()) + .or_insert(HashMap::new()) + .insert(cls_sym.url.clone(), diagnostic); + } + } + self.unresolved_inheritance_references + .entry(old_class_name.to_string()) + .or_insert(Vec::new()) + .extend(direct_dependents); + } + } + } + if is_final_changed && !new_class_is_final { + self.inheritance_diagonstics.remove(old_class_name); + if class_name_changed + && let Some(direct_dependents) = self + .unresolved_inheritance_references + .remove(old_class_name) + { + self.dependent_class_index + .direct_subclasses + .entry(class_id) + .or_insert(HashMap::new()) + .extend(direct_dependents); + self.dependent_class_index + .rebuild_transitive_subclasses(class_id); + } else if let Some(direct_dependents) = self + .unresolved_inheritance_references + .remove(new_class_name) + { + self.inheritance_diagonstics.remove(new_class_name); + self.dependent_class_index + .direct_subclasses + .entry(class_id) + .or_insert(HashMap::new()) + .extend(direct_dependents); + self.dependent_class_index + .rebuild_transitive_subclasses(class_id); + } + } + } + let mut curr_class_hash = HashSet::new(); + curr_class_hash.insert(class_id); + if !new_class_is_final { + if let Some(dependents) = self.dependent_class_index.direct_subclasses.get(&class_id) { + curr_class_hash.extend(dependents.keys().copied()) + } + } + if inheritance_changed { + self.remove_stale_class_from_dependent_classes(class_id, old_inherited_classes); + self.add_dependent_class_to_inherited_class( + class_id, + url.clone(), + new_inherited_classes, + classes_to_recompute_inheritance, + ); + let mut override_rebuild_classes = curr_class_hash.clone(); + override_rebuild_classes.extend(classes_to_recompute_inheritance.iter().copied()); + self.rebuild_override_index_for_classes_and_apply(&override_rebuild_classes); + } + if class_name_changed { + // remove incoming edges from other classes in the dependency graph + let old_method_nodes = self.dependency_graph.get_class_nodes(&class_id); + // remove edges to all method refs of this class (unless they are from a method within this class) + for old_node_index in old_method_nodes { + let method_caller_refs = self + .dependency_graph + .remove_direct_ancestors(old_node_index, &curr_class_hash); + // all old refs to the old class name (and any of its methods) are now unresolved if they aren't from a method in the same class + if let Some(stale_method_ref) = self + .dependency_graph + .get_method_ref_from_node_index(old_node_index) + && let Some(old_method) = + self.global_semantic_model.get_method(stale_method_ref) + { + let old_method_name = old_method.name.clone(); + let old_method_is_final = old_method.is_final; + for (method_ref, method_call_range) in &method_caller_refs { + let lsp_range = ts_range_to_lsp_range(content, *method_call_range); + let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(&method_ref.class) + else { + continue; + }; + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::WARNING), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "Method referenced has either not yet been indexed or does not exist" + ), + related_information: None, + tags: None, + data: None, + }; + self.method_reference_diagnostics + .entry((old_class_name.to_string(), old_method_name.clone())) + .or_insert(HashMap::new()) + .insert(cls_sym.url.clone(), diagnostic); + } + self.unresolved_method_references + .entry((old_class_name.to_string(), old_method_name.clone())) + .or_insert(HashSet::new()) + .extend(method_caller_refs); + if let Some(new_class) = self.global_semantic_model.get_class(&class_id) + && let Some(new_method_ref) = new_class.get_method_ref(&old_method_name) + && new_class + .is_final + .unwrap_or(old_method_is_final.unwrap_or(false)) + { + self.resolve_unresolved_method( + &(new_class_name.to_string(), old_method_name), + *new_method_ref, + ); + } + } + } + self.update_class_name_in_workspace(old_class_name, new_class_name); + } + } + + fn compute_inheritance_override_index_parameter( + &mut self, + classes_to_fully_recompute_inheritance: &HashSet, + subclasses_to_recompute_inheritance: &HashSet, + parameter_name: String, + parameter_ref: ParameterRef, + new_parameter: bool, + class_is_final: bool, + ) { + if !classes_to_fully_recompute_inheritance.is_empty() + && let Some(parameter) = self.global_semantic_model.get_parameter(¶meter_ref) + && (!parameter.is_final.unwrap_or(class_is_final) || !new_parameter) + { + let extended_parameters = self.build_override_index_for_parameter( + &classes_to_fully_recompute_inheritance, + ¶meter_name, + ); + for (extended_class_name, parameter_ref_map) in extended_parameters { + self.parameter_defs + .entry(extended_class_name.clone()) + .or_insert(HashMap::new()) + .extend(parameter_ref_map); + } + } + if !subclasses_to_recompute_inheritance.is_empty() { + let extended_parameters = self.build_override_index_for_parameter( + &subclasses_to_recompute_inheritance, + ¶meter_name, + ); + for (extended_class_name, parameter_ref_map) in extended_parameters { + self.parameter_defs + .entry(extended_class_name.clone()) + .or_insert(HashMap::new()) + .extend(parameter_ref_map); + } + } + } + + fn compute_inheritance_override_index_property( + &mut self, + classes_to_fully_recompute_inheritance: &HashSet, + subclasses_to_recompute_inheritance: &HashSet, + property_name: String, + property_ref: PropertyRef, + new_property: bool, + class_is_final: bool, + ) { + if !classes_to_fully_recompute_inheritance.is_empty() + && let Some(property) = self.global_semantic_model.get_property(&property_ref) + && (!property.is_final.unwrap_or(class_is_final) || !new_property) + { + let extended_properties = self.build_override_index_for_property( + &classes_to_fully_recompute_inheritance, + &property_name, + ); + for (extended_class_name, property_ref_map) in extended_properties { + self.property_defs + .entry(extended_class_name.clone()) + .or_insert(HashMap::new()) + .extend(property_ref_map); + } + } + if !subclasses_to_recompute_inheritance.is_empty() { + let extended_properties = self.build_override_index_for_property( + &subclasses_to_recompute_inheritance, + &property_name, + ); + for (extended_class_name, property_ref_map) in extended_properties { + self.property_defs + .entry(extended_class_name.clone()) + .or_insert(HashMap::new()) + .extend(property_ref_map); + } + } + } + + fn compute_inheritance_override_index_method( + &mut self, + classes_to_fully_recompute_inheritance: &HashSet, + subclasses_to_recompute_inheritance: &HashSet, + method_name: String, + method_ref: MethodRef, + new_method: bool, + class_is_final: bool, + ) { + if !classes_to_fully_recompute_inheritance.is_empty() + && let Some(method) = self.global_semantic_model.get_method(&method_ref) + && (!method.is_final.unwrap_or(class_is_final) || !new_method) + { + let extended_methods = self.build_override_index_for_method( + &classes_to_fully_recompute_inheritance, + &method_name, + ); + for (extended_class_name, method_ref_map) in extended_methods { + self.method_defs + .entry(extended_class_name.clone()) + .or_insert(HashMap::new()) + .extend(method_ref_map); + } + } + if !subclasses_to_recompute_inheritance.is_empty() { + let extended_methods = self.build_override_index_for_method( + &subclasses_to_recompute_inheritance, + &method_name, + ); + for (extended_class_name, method_ref_map) in extended_methods { + self.method_defs + .entry(extended_class_name.clone()) + .or_insert(HashMap::new()) + .extend(method_ref_map); + } + } + } + + fn remove_parameters_in_class( + &mut self, + class_name: &str, + classes_to_fully_recompute_inheritance: &HashSet, + subclasses_to_recompute_inheritance: &HashSet, + new_class_is_final: bool, + ) { + if let Some(parameter_refs) = self.parameter_defs.remove(class_name) { + for (parameter_name, parameter_ref) in parameter_refs { + self.global_semantic_model.remove_parameter(¶meter_ref); + self.compute_inheritance_override_index_parameter( + classes_to_fully_recompute_inheritance, + subclasses_to_recompute_inheritance, + parameter_name.clone(), + parameter_ref, + false, + new_class_is_final, + ); + } + } + } + + fn remove_properties_in_class( + &mut self, + class_name: &str, + classes_to_fully_recompute_inheritance: &HashSet, + subclasses_to_recompute_inheritance: &HashSet, + new_class_is_final: bool, + ) { + if let Some(property_refs) = self.property_defs.remove(class_name) { + for (property_name, property_ref) in property_refs { + self.global_semantic_model.remove_property(&property_ref); + self.compute_inheritance_override_index_property( + classes_to_fully_recompute_inheritance, + subclasses_to_recompute_inheritance, + property_name.clone(), + property_ref, + false, + new_class_is_final, + ); + } + } + } + + fn remove_stale_methods( + &mut self, + stale_methods: &HashSet, + class_name: &str, + classes_to_fully_recompute_inheritance: &HashSet, + subclasses_to_recompute_inheritance: &HashSet, + new_class_is_final: bool, + methods_already_rebuilt: &mut HashSet, + scope_tree: &mut ScopeTree, + content: &str, + ) -> HashSet { + let mut stale_method_refs = HashSet::new(); + // first remove all stale members + for stale_method in stale_methods { + // remove method ref + if let Some(stale_method_ref) = self + .method_defs + .get_mut(class_name) + .and_then(|methods| methods.remove(stale_method)) + { + if let Some(stale_node_index) = self.dependency_graph.get_node(stale_method_ref) { + let method_caller_refs = self + .dependency_graph + .remove_incoming_calls_to_node(*stale_node_index); + if let Some(old_method) = + self.global_semantic_model.remove_method(&stale_method_ref) + { + let old_method_name = old_method.name.clone(); + for (method_ref, method_call_range) in &method_caller_refs { + let lsp_range = ts_range_to_lsp_range(content, *method_call_range); + let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(&method_ref.class) + else { + continue; + }; + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::WARNING), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "Method referenced has either not yet been indexed or does not exist" + ), + related_information: None, + tags: None, + data: None, + }; + self.method_reference_diagnostics + .entry((class_name.to_string(), old_method_name.clone())) + .or_insert(HashMap::new()) + .insert(cls_sym.url.clone(), diagnostic); + } + self.unresolved_method_references + .entry((class_name.to_string(), old_method_name.clone())) + .or_insert(HashSet::new()) + .extend(method_caller_refs); + self.compute_inheritance_override_index_method( + classes_to_fully_recompute_inheritance, + subclasses_to_recompute_inheritance, + old_method_name.clone(), + stale_method_ref, + false, + new_class_is_final, + ); + methods_already_rebuilt.insert(old_method_name); + scope_tree.private_method_defs.remove(&stale_method_ref); + } + } + stale_method_refs.insert(stale_method_ref); + for method_map in self.pub_var_defs.values_mut() { + method_map.remove(&stale_method_ref); + } + } + } + stale_method_refs + } + + fn incremental_remove_stale_class_members( + &mut self, + stale_methods: &HashSet, + new_class_name: &str, + old_class_name: &str, + classes_to_fully_recompute_inheritance: &HashSet, + subclasses_to_recompute_inheritance: &HashSet, + new_class_is_final: bool, + old_class_is_final: bool, + methods_already_rebuilt: &mut HashSet, + scope_tree: &mut ScopeTree, + content: &str, + ) { + self.remove_stale_methods( + stale_methods, + new_class_name, + &classes_to_fully_recompute_inheritance, + &subclasses_to_recompute_inheritance, + new_class_is_final, + methods_already_rebuilt, + scope_tree, + content, + ); + // remove all property_defs and parameter defs for the class (will be rebuilt fully) + // note: already removed from scope tree because it was rebuilt and the old defs were not copied over for params/properties + self.remove_properties_in_class( + &old_class_name, + &classes_to_fully_recompute_inheritance, + &subclasses_to_recompute_inheritance, + old_class_is_final, + ); + + self.remove_parameters_in_class( + &old_class_name, + &classes_to_fully_recompute_inheritance, + &subclasses_to_recompute_inheritance, + old_class_is_final, + ); + } + + /// Returns true if successful, false otherwise + pub fn incremental_update_document( + &mut self, + url: Url, + tree: &Tree, + file_type: FileType, + version: i32, + content: &str, + changed_ranges: Vec, + new_class_name: String, + new_class_range: Range, + class_name_def_range: Range, + ) { + self.clear_diagnostics_for_url(&url); + + if file_type == FileType::Xml { + let Some(document) = self.get_document_mut(&url) else { + generic_exit_statements("Error: document DNE for path: {:?}", url.path()); + self.add_document( + url, + content, + tree, + file_type, + None, + new_class_name, + Some(version), + new_class_range, + ); + return; + }; + document.version = Some(version); + document.file_type = file_type; + document.tree = tree.clone(); document.content = content.to_string(); - document.class_name = None; + document.class_name = new_class_name; 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 + } 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(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) = { + if let Some(document) = self.get_document_mut(&url) { + document.class_name = new_class_name.clone(); + document.file_type = file_type; + document.content = content.to_string(); + document.tree = tree.clone(); + document.version = Some(version); + } + + let ( + old_class_id, + old_class_name, + old_is_final, + old_inherited_classes, + old_scope_tree, + old_method_names, + ) = { let Some(doc) = self.get_document(&url) else { eprintln!( "Error: Document for url {:?} DNE aborting update_document", url.path() ); + let class_id = if let Some(id) = self.classes.get(&new_class_name) { + *id + } else { + ClassId(self.global_semantic_model.next_id()) + }; + self.full_update_document( + url, + content, + tree, + file_type, + class_id, + new_class_name, + Some(version), + new_class_range, + ); return; }; let Some(cls_id) = doc.class_id else { @@ -440,652 +1783,2074 @@ impl ProjectData { "Error: Class ID for document {:?} DNE aborting update_document", doc ); + let class_id = if let Some(id) = self.classes.get(&new_class_name) { + *id + } else { + ClassId(self.global_semantic_model.next_id()) + }; + self.full_update_document( + url, + content, + tree, + file_type, + class_id, + new_class_name, + Some(version), + new_class_range, + ); return; }; - let Some(old_member_name) = doc.class_name.clone() else { - eprintln!( - "Error: Name for document {:?} DNE aborting update_document", - doc + let old_member_name = doc.class_name.clone(); + let Some(class) = self.global_semantic_model.get_class(&cls_id) else { + self.full_update_document( + url, + content, + tree, + file_type, + cls_id, + new_class_name, + Some(version), + new_class_range, ); 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 old_method_names: HashSet = class.methods.keys().cloned().collect(); - { - let Some(doc) = self.get_document_mut(&url) else { - generic_exit_statements("ProjectData", "update_document"); + ( + cls_id, + old_member_name, + class.is_final.clone(), + class.inherited_classes.clone(), + doc.scope_tree.clone(), + old_method_names, + ) + }; + if &old_class_name != &new_class_name { + if self.classes.contains_key(&new_class_name) { + let lsp_range = ts_range_to_lsp_range(content, class_name_def_range); + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::ERROR), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "A Class named {:?} already exists in this workspace.", + &new_class_name + ), + related_information: None, + tags: None, + data: None, + }; + self.other_class_diagnostics + .entry(url.clone()) + .or_insert(Vec::new()) + .push(diagnostic); + eprintln!( + "Error: A class with name {:?} already exists, aborting (incremental_update_document)", + &new_class_name + ); 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 + // this updates all the class members in the class itself, and then use the + // returned results to update the global semantic model/ local semantic model/ scope tree + let ( + inheritance_changed, + stale_methods, + new_methods, + properties_already_rebuilt, + parameters_already_rebuilt, + new_inherited_classes, + all_methods, + class_diagnostics, + ) = { + let Some(class) = self.global_semantic_model.get_mut_class(&old_class_id) else { + self.full_update_document( + url, + content, + tree, + file_type, + old_class_id, + new_class_name, + Some(version), + new_class_range, ); return; }; - node + class.build_imports(tree, content); + class.build_class( + tree.root_node(), + content, + is_rtn, + &old_class_id, + new_class_range, + &new_class_name, + ) }; + self.other_class_diagnostics + .insert(url.clone(), class_diagnostics); + let all_methods_set: HashSet = all_methods.keys().cloned().collect(); + let total_methods: HashSet = + old_method_names.union(&all_methods_set).cloned().collect(); + // update class symbol and class ref + if let Some(class_symbol) = self + .global_semantic_model + .get_class_symbol_mut(&old_class_id) + { + class_symbol.name = new_class_name.clone(); + class_symbol.location = new_class_range; + class_symbol.alive = true; + } - self.rebuild_semantics( - url.clone(), - starting_node, + self.classes.insert(new_class_name.clone(), old_class_id); + + // rebuild scope tree + let mut scope_tree = initial_build_scope_tree(&tree, old_class_id, content, is_rtn); + // copy over the old variable defs from the old scope tree into the new rebuilt scope tree + let old_class_member_scopes = old_scope_tree.get_root_children_scopes(); + for old_scope in old_class_member_scopes { + scope_tree.copy_method_scope(&old_scope, &old_scope_tree); + } + scope_tree.private_method_defs = old_scope_tree.private_method_defs; + // NOTE: property defs are never copied over because they are fully rebuilt + + // rebuild keywords for class (is_procedure, is_final, language) + self.rebuild_keyword_inheritance_for_class(&old_class_id); + // NOTE: this must be checked after the keyword inheritance is completed. + let (new_class_is_final, new_class_is_procedure_block) = { + if let Some(class) = self.global_semantic_model.get_class(&old_class_id) { + (class.is_final, class.is_procedure_block) + } else { + self.full_update_document( + url, + content, + tree, + file_type, + old_class_id, + new_class_name, + Some(version), + new_class_range, + ); + return; + } + }; + // classes to recompute inheritance includes all classes that + // the override index should be rebuilt for + let mut classes_to_fully_recompute_inheritance = HashSet::new(); + let mut methods_already_rebuilt = HashSet::new(); + self.update_class_inheritance( + &old_class_name, + &new_class_name, + old_class_id, + &old_inherited_classes, + &new_inherited_classes, + inheritance_changed, + old_is_final.unwrap_or(false), + new_class_is_final.unwrap_or(false), + &mut classes_to_fully_recompute_inheritance, + &url, + content, + ); + // this consists of all subclasses that are NOT in classes_to_fully_recompute_inheritance + let subclasses_to_recompute_inheritance: HashSet = self + .dependent_class_index + .dependent_classes + .get(&old_class_id) + .unwrap_or(&HashSet::new()) + .difference(&classes_to_fully_recompute_inheritance) + .cloned() + .collect(); + + self.incremental_remove_stale_class_members( + &stale_methods, + &new_class_name, + &old_class_name, + &classes_to_fully_recompute_inheritance, + &subclasses_to_recompute_inheritance, + new_class_is_final.unwrap_or(false), + old_is_final.unwrap_or(false), + &mut methods_already_rebuilt, + &mut scope_tree, content, - cls_id, - member_name.clone(), - file_type.clone(), ); + let mut unresolved_orefs = HashMap::new(); + + for (method_name, (mut method, method_range, method_ref, public_variables_declared)) in + new_methods { - 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); + let mut unresolved_method_refs = HashSet::new(); + let mut unresolved_oref_method_refs = HashSet::new(); + let mut variable_info = Vec::new(); + methods_already_rebuilt.insert(method_name.clone()); + self.dependency_graph.get_or_add_node(method_ref); + let method_type = method.method_type.clone(); + match method_type { + MethodType::ClassMethod | MethodType::InstanceMethod => { + if let Some(method_definition_node) = + tree.root_node().named_descendant_for_byte_range( + method_range.start_byte, + method_range.end_byte, + ) + { + ( + _, + _, + variable_info, + unresolved_method_refs, + unresolved_oref_method_refs, + ) = method.rebuild_method( + method_definition_node, + content, + &scope_tree, + method_type, + method_range, + public_variables_declared, + new_class_is_final, + old_is_final, + new_class_is_procedure_block, + &new_class_name, + ); + } + } + MethodType::Procedure(_) => { + if let Some(method_definition_node) = + tree.root_node().named_descendant_for_byte_range( + method_range.start_byte, + method_range.end_byte, + ) + { + ( + _, + _, + variable_info, + unresolved_method_refs, + unresolved_oref_method_refs, + ) = method.rebuild_method( + method_definition_node, + content, + &scope_tree, + method_type, + method_range, + public_variables_declared, + new_class_is_final, + old_is_final, + new_class_is_procedure_block, + &new_class_name, + ); + } + } + MethodType::Subroutine(_) + | MethodType::Routine + | MethodType::DottedSubroutine(_) => { + ( + _, + _, + variable_info, + unresolved_method_refs, + unresolved_oref_method_refs, + ) = method.rebuild_method( + tree.root_node(), + content, + &scope_tree, + method_type, + method_range, + public_variables_declared, + new_class_is_final, + old_is_final, + new_class_is_procedure_block, + &new_class_name, + ); + } + } + + for (variable, variable_range, variable_dependencies, variable_scope_id) in + variable_info + { + let variable_name = variable.name.clone(); + let variable_is_public = variable.is_public; + // add it to global semantic model (if public) or local semantic model/scope tree (if private) + // global semantic will add it to local semantic if private + let variable_ref = self.global_semantic_model.new_variable( + variable, + method_ref, + variable_scope_id, + variable_dependencies.clone(), + variable_range, + url.clone(), + ); + + // add variable ref and corresponding scope id to method + method + .variables + .entry(variable_name.clone()) + .or_insert(Vec::new()) + .push((variable_ref, variable_scope_id)); + + if variable_is_public { + scope_tree.new_public_var_symbol( + variable_name.clone(), + variable_range, + variable_ref, + ); + self.pub_var_defs + .entry(variable_name) + .or_insert(HashMap::new()) + .entry(method_ref) + .or_insert(HashMap::new()) + .entry(variable_scope_id) + .or_insert(Vec::new()) + .push(variable_ref); + } else { + scope_tree.new_variable_symbol( + variable_name, + variable_range, + variable_dependencies, + variable_ref, + ); + } + } + self.dependency_graph.get_or_add_node(method_ref); + self.method_defs + .entry(new_class_name.clone()) + .or_insert_with(HashMap::new) + .insert(method_name.clone(), method_ref); + self.resolve_method_references( + content, + &unresolved_method_refs, + method_ref, + old_class_id, + ); + unresolved_orefs.insert(method_ref, unresolved_oref_method_refs); + if !method.is_public { + // creates method symbol in scope tree (private) + scope_tree.new_method_symbol( + method_name.clone(), + method_range, + method_ref, + url.clone(), + ); + } + // adds method to global semantic model if public, and to local semantic model if private + // also, creates method symbol if public + self.global_semantic_model.new_method( + method, + method_ref, + method_range, + url.clone(), + ); + self.compute_inheritance_override_index_method( + &classes_to_fully_recompute_inheritance, + &subclasses_to_recompute_inheritance, + method_name, + method_ref, + true, + new_class_is_final.unwrap_or(false), + ); + } + for (method_ref, unresolved_oref_methods) in unresolved_orefs { + for (oref_name, oref_method_name, method_call_range, current_method_name) in + unresolved_oref_methods + { + let (resolved, unresolved) = self.resolve_oref_methods( + method_ref, + &oref_name, + &oref_method_name, + method_call_range, + ¤t_method_name, + &scope_tree, + ); + for (key, value) in unresolved { + for (method_ref, method_call_range) in &value { + let lsp_range = ts_range_to_lsp_range(content, *method_call_range); + let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(&method_ref.class) + else { + continue; + }; + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::WARNING), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "Method referenced has either not yet been indexed or does not exist" + ), + related_information: None, + tags: None, + data: None, + }; + self.method_reference_diagnostics + .entry(key.clone()) + .or_insert(HashMap::new()) + .insert(cls_sym.url.clone(), diagnostic); + } + self.unresolved_method_references + .entry(key) + .or_insert(HashSet::new()) + .extend(value); + } + for referenced_method_ref in &resolved { + self.dependency_graph.add_edge( + method_ref, + *referenced_method_ref, + method_call_range, + ); + } + } + } + + for (property_name, (property, property_range, property_ref)) in + properties_already_rebuilt + { + if !property.is_public { + // creates and stores property symbol in scope tree + scope_tree.new_property_symbol( + property_name.clone(), + property_range, + property_ref, + url.clone(), + ) + } + // adds property to gsm if public, lsm if private + // if public, creates and stores property symbol in gsm + self.global_semantic_model.new_property( + property, + property_ref, + property_range, + url.clone(), + ); + self.property_defs + .entry(new_class_name.clone()) + .or_insert_with(HashMap::new) + .insert(property_name.clone(), property_ref); + + self.compute_inheritance_override_index_property( + &classes_to_fully_recompute_inheritance, + &subclasses_to_recompute_inheritance, + property_name, + property_ref, + true, + new_class_is_final.unwrap_or(false), + ); + } + + for (parameter_name, (parameter, parameter_range, parameter_ref)) in + parameters_already_rebuilt + { + // creates property symbol and stores it in global semantic model + // also stores parameter in gsm + self.global_semantic_model.new_parameter( + parameter, + parameter_ref, + parameter_range, + url.clone(), + ); + // add property ref to workspace + self.parameter_defs + .entry(new_class_name.clone()) + .or_insert_with(HashMap::new) + .insert(parameter_name.clone(), parameter_ref); + self.compute_inheritance_override_index_parameter( + &classes_to_fully_recompute_inheritance, + &subclasses_to_recompute_inheritance, + parameter_name, + parameter_ref, + true, + new_class_is_final.unwrap_or(false), + ); + } + + let scope_tree_snapshot = scope_tree.clone(); + let mut curr_class_hash = HashSet::new(); + curr_class_hash.insert(old_class_id); + if !new_class_is_final.unwrap_or(false) { + if let Some(dependents) = self + .dependent_class_index + .direct_subclasses + .get(&old_class_id) + { + curr_class_hash.extend(dependents.keys().copied()) + } + } + let mut unresolved_orefs = HashMap::new(); + for ts_range in changed_ranges { + // this only gives direct children of the root scope (so methods, properties, etc) + let scopes_within_changed_range = scope_tree_snapshot + .find_scopes_in_range(ts_range.start_point, ts_range.end_point); + // this can cover multiple methods + for (_, curr_scope) in scopes_within_changed_range { + if let Some(method_name) = &curr_scope.method { + if methods_already_rebuilt.contains(method_name) { + continue; + } + methods_already_rebuilt.insert(method_name.clone()); + + let Some((method_range, method_type, public_variables_declared)) = + all_methods.get(method_name) + else { + continue; + }; + let mut unresolved_method_refs = HashSet::new(); + let mut unresolved_oref_method_refs = HashSet::new(); + let mut variable_info = Vec::new(); + let mut method_is_final_changed = false; + let mut method_is_public_changed = false; + let Some(method_ref) = self + .method_defs + .get(&new_class_name) + .and_then(|methods| methods.get(method_name)) + .copied() + else { + eprintln!("error: method DNE"); + continue; + }; + if let Some(method) = self.global_semantic_model.get_mut_method(&method_ref) + { + match method_type { + MethodType::ClassMethod | MethodType::InstanceMethod => { + if let Some(method_definition_node) = + tree.root_node().named_descendant_for_byte_range( + method_range.start_byte, + method_range.end_byte, + ) + { + ( + method_is_final_changed, + method_is_public_changed, + variable_info, + unresolved_method_refs, + unresolved_oref_method_refs, + ) = method.rebuild_method( + method_definition_node, + content, + &scope_tree, + *method_type, + *method_range, + public_variables_declared.clone(), + new_class_is_final, + old_is_final, + new_class_is_procedure_block, + &new_class_name, + ); + } + } + MethodType::Procedure(_) => { + if let Some(method_definition_node) = + tree.root_node().named_descendant_for_byte_range( + method_range.start_byte, + method_range.end_byte, + ) + { + ( + method_is_final_changed, + method_is_public_changed, + variable_info, + unresolved_method_refs, + unresolved_oref_method_refs, + ) = method.rebuild_method( + method_definition_node, + content, + &scope_tree, + *method_type, + *method_range, + public_variables_declared.clone(), + new_class_is_final, + old_is_final, + new_class_is_procedure_block, + &new_class_name, + ); + } + } + MethodType::Subroutine(_) + | MethodType::Routine + | MethodType::DottedSubroutine(_) => { + ( + method_is_final_changed, + method_is_public_changed, + variable_info, + unresolved_method_refs, + unresolved_oref_method_refs, + ) = method.rebuild_method( + tree.root_node(), + content, + &scope_tree, + *method_type, + *method_range, + public_variables_declared.clone(), + new_class_is_final, + old_is_final, + new_class_is_procedure_block, + &new_class_name, + ); + } + } + } + + if method_is_final_changed { + self.compute_inheritance_override_index_method( + &classes_to_fully_recompute_inheritance, + &HashSet::new(), + method_name.clone(), + method_ref, + true, + new_class_is_final.unwrap_or(false), + ); + } + if method_is_public_changed { + let mut method_is_public = true; + if let Some(method) = self.global_semantic_model.get_method(&method_ref) + { + if !method.is_public { + method_is_public = false; + } + } + let node_index = self.dependency_graph.get_or_add_node(method_ref); + if !method_is_public { + let method_caller_refs = self + .dependency_graph + .remove_direct_ancestors(node_index, &curr_class_hash); + for (method_ref, method_call_range) in &method_caller_refs { + let lsp_range = + ts_range_to_lsp_range(content, *method_call_range); + let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(&method_ref.class) + else { + continue; + }; + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::WARNING), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "Method referenced has either not yet been indexed or does not exist" + ), + related_information: None, + tags: None, + data: None, + }; + self.method_reference_diagnostics + .entry((new_class_name.to_string(), method_name.clone())) + .or_insert(HashMap::new()) + .insert(cls_sym.url.clone(), diagnostic); + } + self.unresolved_method_references + .entry((new_class_name.to_string(), method_name.clone())) + .or_insert(HashSet::new()) + .extend(method_caller_refs); + } + self.global_semantic_model.change_method_publicity( + &method_ref, + *method_range, + url.clone(), + ); + } + self.resolve_method_references( + content, + &unresolved_method_refs, + method_ref, + old_class_id, + ); + self.compute_inheritance_override_index_method( + &curr_class_hash, + &HashSet::new(), + method_name.clone(), + method_ref, + false, + new_class_is_final.unwrap_or(false), + ); + unresolved_orefs.insert(method_ref, unresolved_oref_method_refs); + for (variable, variable_range, variable_dependencies, variable_scope_id) in + variable_info + { + let variable_name = variable.name.clone(); + let variable_is_public = variable.is_public; + // add it to global semantic model (if public) or local semantic model/scope tree (if private) + // global semantic will add it to local semantic if private + let variable_ref = self.global_semantic_model.new_variable( + variable, + method_ref, + variable_scope_id, + variable_dependencies.clone(), + variable_range, + url.clone(), + ); + + if let Some(method) = + self.global_semantic_model.get_mut_method(&method_ref) + { + // add variable ref and corresponding scope id to method + method + .variables + .entry(variable_name.clone()) + .or_insert(Vec::new()) + .push((variable_ref, variable_scope_id)); + } + + if variable_is_public { + scope_tree.new_public_var_symbol( + variable_name.clone(), + variable_range, + variable_ref, + ); + self.pub_var_defs + .entry(variable_name) + .or_insert(HashMap::new()) + .entry(method_ref) + .or_insert(HashMap::new()) + .entry(variable_scope_id) + .or_insert(Vec::new()) + .push(variable_ref); + } else { + scope_tree.new_variable_symbol( + variable_name, + variable_range, + variable_dependencies, + variable_ref, + ); + } + } + } else { + continue; + } + } + } + + for (method_ref, unresolved_oref_methods) in unresolved_orefs { + for (oref_name, oref_method_name, method_call_range, current_method_name) in + unresolved_oref_methods + { + let (resolved, unresolved) = self.resolve_oref_methods( + method_ref, + &oref_name, + &oref_method_name, + method_call_range, + ¤t_method_name, + &scope_tree, + ); + for (key, value) in unresolved { + for (method_ref, method_call_range) in &value { + let lsp_range = ts_range_to_lsp_range(content, *method_call_range); + let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(&method_ref.class) + else { + continue; + }; + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::WARNING), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "Method referenced has either not yet been indexed or does not exist" + ), + related_information: None, + tags: None, + data: None, + }; + self.method_reference_diagnostics + .entry(key.clone()) + .or_insert(HashMap::new()) + .insert(cls_sym.url.clone(), diagnostic); + } + self.unresolved_method_references + .entry(key) + .or_insert(HashSet::new()) + .extend(value); + } + for referenced_method_ref in &resolved { + self.dependency_graph.add_edge( + method_ref, + *referenced_method_ref, + method_call_range, + ); + } + } + } + let Some(doc) = self.get_document_mut(&url) else { + self.full_update_document( + url, + content, + tree, + file_type, + old_class_id, + new_class_name, + Some(version), + new_class_range, + ); + return; + }; + doc.scope_tree = scope_tree; + for method_name in total_methods { + if methods_already_rebuilt.contains(&method_name) { + continue; + } + if !classes_to_fully_recompute_inheritance.is_empty() { + let extended_methods = self.build_override_index_for_method( + &classes_to_fully_recompute_inheritance, + &method_name, + ); + for (extended_class_name, method_ref_map) in extended_methods { + self.method_defs + .entry(extended_class_name.clone()) + .or_insert(HashMap::new()) + .extend(method_ref_map); + } + } } - self.build_inheritance_and_variables(Some(url), Vec::new()); return; } + let class_id = if let Some(id) = self.classes.get(&new_class_name) { + *id + } else { + ClassId(self.global_semantic_model.next_id()) + }; + self.full_update_document( + url, + content, + tree, + file_type, + class_id, + new_class_name, + Some(version), + new_class_range, + ); + 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, + /// Resolves oref method references for a given method. Searches local scopes, parent scopes, + /// child scopes, and then walks the dependency graph to find where the oref variable is defined. + /// Returns the set of MethodRefs that the oref could be calling. + pub fn resolve_oref_methods( + &self, + method_ref: MethodRef, + oref_name: &str, + oref_method_name: &str, + method_call_range: Range, + current_method_name: &str, + scope_tree: &ScopeTree, + ) -> ( + HashSet, + HashMap<(String, String), HashSet<(MethodRef, Range)>>, ) { - let is_rtn = if file_type == FileType::Routine { - true - } else { - false + let mut all_possible_oref_methods = HashSet::new(); + let mut unresolved_method_references = HashMap::new(); + + let Some((scope_id, scope)) = scope_tree.get_scope(method_call_range.start_point) else { + return (all_possible_oref_methods, unresolved_method_references); }; - // 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, + + let oref_is_public = + if let Some(method) = self.global_semantic_model.get_method(&method_ref) { + method + .public_variables_declared + .contains(&oref_name.to_string()) + || method.method_type == MethodType::Routine + || matches!(method.method_type, MethodType::Subroutine(_)) + || matches!(method.method_type, MethodType::DottedSubroutine(_)) + } else { + false }; - 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); + + if let Some((oref_def_range, oref_class_name)) = + self.global_semantic_model.get_oref_in_scope_before_range( + method_ref, + scope_id, + oref_name, + method_call_range, + &scope.variable_symbols, + ) + { + if let Some(referenced_method_ref) = self + .method_defs + .get(&oref_class_name) + .and_then(|methods| methods.get(oref_method_name)) + { + all_possible_oref_methods.insert(*referenced_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, + unresolved_method_references + .entry((oref_class_name, oref_method_name.to_string())) + .or_insert(HashSet::new()) + .insert((method_ref, oref_def_range)); + } + } else if let Some(method_scope_id) = + scope_tree.find_scope_by_method_name(current_method_name) + && let Some(method_scope) = scope_tree.scopes.get(&method_scope_id) + { + let potential_scopes: Vec; + if method_scope_id != scope_id { + if let Some((oref_def_range, oref_class_name)) = + self.global_semantic_model.get_oref_in_scope_before_range( + method_ref, + method_scope_id, + oref_name, + method_call_range, + &scope.variable_symbols, + ) + { + if let Some(referenced_method_ref) = self + .method_defs + .get(&oref_class_name) + .and_then(|methods| methods.get(oref_method_name)) + { + all_possible_oref_methods.insert(*referenced_method_ref); + potential_scopes = scope_tree.get_children_before_scope_id( + method_call_range.start_point, + Some(oref_def_range.start_point), + &method_scope.children, + ); + } else { + unresolved_method_references + .entry((oref_class_name, oref_method_name.to_string())) + .or_insert(HashSet::new()) + .insert((method_ref, oref_def_range)); + potential_scopes = scope_tree.get_children_before_scope_id( + method_call_range.start_point, + None, + &method_scope.children, + ); + } + } else { + potential_scopes = scope_tree.get_children_before_scope_id( + method_call_range.start_point, + None, + &method_scope.children, + ); + } + } else { + potential_scopes = scope_tree.get_children_before_scope_id( + method_call_range.start_point, + None, + &method_scope.children, ); - self.method_defs - .entry(class_name.clone()) - .or_insert_with(HashMap::new) - .insert(method_name.clone(), method_ref); + } + for child_scope_id in potential_scopes { + if let Some((oref_def_range, oref_class_name)) = + self.global_semantic_model.get_oref_in_scope_before_range( + method_ref, + child_scope_id, + oref_name, + method_call_range, + &scope.variable_symbols, + ) + { + if let Some(referenced_method_ref) = self + .method_defs + .get(&oref_class_name) + .and_then(|methods| methods.get(oref_method_name)) + { + all_possible_oref_methods.insert(*referenced_method_ref); + } else { + unresolved_method_references + .entry((oref_class_name, oref_method_name.to_string())) + .or_insert(HashSet::new()) + .insert((method_ref, oref_def_range)); + } + } + } + if all_possible_oref_methods.is_empty() && oref_is_public { + let Some(&node_index) = self.dependency_graph.get_node(method_ref) else { + return (all_possible_oref_methods, unresolved_method_references); + }; + + let mut visited = HashSet::new(); + let mut queue = std::collections::VecDeque::new(); + visited.insert(node_index); + + for edge in self + .dependency_graph + .graph + .edges_directed(node_index, petgraph::Direction::Incoming) + { + let parent = edge.source(); + if visited.insert(parent) { + queue.push_back((parent, *edge.weight())); + } + } + + while let Some((node, call_range)) = queue.pop_front() { + let ancestor_ref = self.dependency_graph.graph[node]; + let mut found_def = false; + + if let Some(scopes) = self + .pub_var_defs + .get(oref_name) + .and_then(|m| m.get(&ancestor_ref)) + { + for (sid, variable_refs) in scopes { + 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(&ancestor_ref, var_id.0, sid) + && symbol.location.end_byte < call_range.start_byte + { + found_def = true; + if let Some(var) = self + .global_semantic_model + .variables + .get(&ancestor_ref) + .and_then(|s| s.get(sid)) + .and_then(|vars| vars.get(var_id.0)) + && var.is_oref + && let Some(ref oref_cls) = var.cls + { + if let Some(referenced_method_ref) = self + .method_defs + .get(oref_cls) + .and_then(|methods| methods.get(oref_method_name)) + { + all_possible_oref_methods + .insert(*referenced_method_ref); + } else { + unresolved_method_references + .entry(( + oref_cls.to_string(), + oref_method_name.to_string(), + )) + .or_insert(HashSet::new()) + .insert((method_ref, symbol.location)); + } + } + } + } + } + } + + if !found_def { + for edge in self + .dependency_graph + .graph + .edges_directed(node, petgraph::Direction::Incoming) + { + let parent = edge.source(); + if visited.insert(parent) { + queue.push_back((parent, *edge.weight())); + } + } + } + } } } - self.global_semantic_model.classes.insert(class_id, class); - let Some(doc) = self.get_document_mut(&url) else { + + return (all_possible_oref_methods, unresolved_method_references); + } + + /// Rebuild keyword inheritance (is_procedure_block, default_language, is_final) + /// for a single class by walking up the primary parent chain. + pub fn rebuild_keyword_inheritance_for_class(&mut self, class_id: &ClassId) { + let Some(class) = self.global_semantic_model.get_class(class_id) else { return; }; - doc.class_id = Some(class_id); + if class.is_procedure_block.is_some() + && class.default_language.is_some() + && class.is_final.is_some() + { + return; + } + + let mut pb = class.is_procedure_block; + let mut lang = class.default_language.clone(); + let mut is_final = class.is_final; + let mut current_parents = class.inherited_classes.clone(); + let mut visited = HashSet::new(); + + while pb.is_none() || lang.is_none() || is_final.is_none() { + let Some((parent_name, _)) = current_parents.get(0) else { + break; + }; + if !visited.insert(parent_name.clone()) { + break; + } + let Some(&parent_id) = self.classes.get(parent_name) else { + break; + }; + let Some(parent) = self.global_semantic_model.get_class(&parent_id) else { + break; + }; + if pb.is_none() { + pb = parent.is_procedure_block; + } + if lang.is_none() { + lang = parent.default_language.clone(); + } + if is_final.is_none() { + is_final = parent.is_final; + } + current_parents = parent.inherited_classes.clone(); + } + + if let Some(class) = self.global_semantic_model.get_mut_class(class_id) { + if class.is_procedure_block.is_none() { + class.is_procedure_block = pb; + } + if class.default_language.is_none() { + class.default_language = lang; + } + if class.is_final.is_none() { + class.is_final = is_final; + } + } } - /// Compute imports, inheritance, override resolution, call sites, and variable symbols. + fn rebuild_override_index_for_classes_and_apply( + &mut self, + affected_classes: &HashSet, + ) { + let (extended_methods, extended_properties, extended_parameters) = + self.build_override_index_for_classes(affected_classes); + + for (class_name, methods) in extended_methods { + self.method_defs + .entry(class_name) + .or_insert_with(HashMap::new) + .extend(methods); + } + for (class_name, properties) in extended_properties { + self.property_defs + .entry(class_name) + .or_insert_with(HashMap::new) + .extend(properties); + } + for (class_name, parameters) in extended_parameters { + self.parameter_defs + .entry(class_name) + .or_insert_with(HashMap::new) + .extend(parameters); + } + } + + /// Rebuild the override index for a specific set of classes. /// - /// 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; + /// Clears all override entries belonging to the affected classes, then recomputes + /// their effective tables and override relationships using the (already correct) + /// parent tables in the existing index. + /// + /// `affected_classes` should include the changed class AND all its transitive + /// dependents (subclasses). Parent classes must NOT be in this set unless they + /// also changed. + pub fn build_override_index_for_classes( + &mut self, + affected_classes: &HashSet, + ) -> ( + HashMap>, + HashMap>, + HashMap>, + ) { + let mut extended_methods = HashMap::new(); + let mut extended_properties = HashMap::new(); + let mut extended_parameters = HashMap::new(); + let mut cls_name_to_id = HashMap::new(); + let mut cls_id_to_name = HashMap::new(); + for class_id in affected_classes { + if let Some(class_name) = self + .global_semantic_model + .get_class(class_id) + .map(|c| c.name.clone()) + { + cls_name_to_id.insert(class_name.clone(), *class_id); + cls_id_to_name.insert(*class_id, class_name); } + } + // Clear old entries for affected classes + for &class_id in affected_classes { + let Some(class_name) = cls_id_to_name.get(&class_id) else { + continue; + }; + self.override_index.effective_methods.remove(class_name); + self.override_index.effective_properties.remove(class_name); + self.override_index.effective_parameters.remove(class_name); - 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 + self.override_index + .method_overrides + .retain(|child, _| child.class != class_id); + self.override_index + .property_overrides + .retain(|child, _| child.class != class_id); + self.override_index + .parameter_overrides + .retain(|child, _| child.class != class_id); + + self.override_index + .method_overridden_by + .retain(|parent, _| parent.class != class_id); + self.override_index + .property_overridden_by + .retain(|parent, _| parent.class != class_id); + self.override_index + .parameter_overridden_by + .retain(|parent, _| parent.class != class_id); + + for children in self.override_index.method_overridden_by.values_mut() { + children.retain(|child| child.class != class_id); + } + for children in self.override_index.property_overridden_by.values_mut() { + children.retain(|child| child.class != class_id); + } + for children in self.override_index.parameter_overridden_by.values_mut() { + children.retain(|child| child.class != class_id); + } + } + + // Rebuild in topological order via BFS from roots (classes whose parents + // are all outside the affected set, i.e. already correct). + let mut in_degree: HashMap = affected_classes + .iter() + .map(|&class_id| { + let dep_count = self + .global_semantic_model + .get_class(&class_id) + .map(|c| { + c.inherited_classes + .iter() + .filter(|(parent_name, _)| cls_name_to_id.contains_key(parent_name)) + .count() + }) + .unwrap_or(0); + (class_id, dep_count) + }) + .collect(); + + let mut queue: std::collections::VecDeque = in_degree + .iter() + .filter(|(_, deg)| **deg == 0) + .map(|(cid, _)| *cid) + .collect(); + + let mut ordered: Vec = Vec::with_capacity(affected_classes.len()); + while let Some(cid) = queue.pop_front() { + ordered.push(cid); + if let Some(dependents) = self.dependent_class_index.direct_subclasses.get(&cid) { + for child in dependents.keys() { + if let Some(deg) = in_degree.get_mut(child) { + *deg = deg.saturating_sub(1); + if *deg == 0 { + queue.push_back(*child); + } + } + } + } + } + for class_id in ordered { + let Some(class) = self.global_semantic_model.get_class(&class_id) else { + continue; }; - // 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(); + let class_name = class.name.clone(); + let inheritance_direction = class.inheritance_direction.clone(); + let parents = class.inherited_classes.clone(); + let methods: Vec<(String, MethodRef, bool)> = class + .methods + .iter() + .map(|(name, mref)| { + let is_public = self.global_semantic_model.get_method(mref).is_some(); + (name.clone(), *mref, is_public) + }) + .collect(); + let properties: Vec<(String, PropertyRef, bool)> = class + .properties + .iter() + .map(|(name, pref)| { + let is_public = self.global_semantic_model.get_property(pref).is_some(); + (name.clone(), *pref, is_public) + }) + .collect(); + let parameters: Vec<(String, ParameterRef)> = class + .parameters + .iter() + .map(|(name, pref)| (name.clone(), *pref)) + .collect(); + + // Build effective tables from parents + let mut method_table: HashMap = HashMap::new(); + let mut property_table: HashMap = HashMap::new(); + let mut parameter_table: HashMap = HashMap::new(); + + let parent_names: Vec = if let Some(inheritance_direction) = + inheritance_direction + && inheritance_direction == "right" + { + parents.iter().rev().map(|(name, _)| name.clone()).collect() + } else { + parents.iter().map(|(name, _)| name.clone()).collect() + }; + + for parent_name in &parent_names { + if let Some(parent_methods) = self.override_index.effective_methods.get(parent_name) + { + for (name, mref) in parent_methods { + method_table.entry(name.clone()).or_insert((*mref, true)); + } + } + if let Some(parent_props) = + self.override_index.effective_properties.get(parent_name) + { + for (name, pref) in parent_props { + property_table.entry(name.clone()).or_insert((*pref, true)); + } + } + if let Some(parent_params) = + self.override_index.effective_parameters.get(parent_name) + { + for (name, pref) in parent_params { + parameter_table.entry(name.clone()).or_insert((*pref, true)); + } + } + } + + // Overlay this class's parameters + for (name, child_ref) in ¶meters { + if let Some((base_ref, true)) = parameter_table.get(name).copied() { + self.override_index + .parameter_overrides + .insert(*child_ref, base_ref); + self.override_index + .parameter_overridden_by + .entry(base_ref) + .or_default() + .push(*child_ref); + } + parameter_table.insert(name.clone(), (*child_ref, true)); + } + + // Overlay this class's methods + for (name, child_ref, is_public) in &methods { + if *is_public { + if let Some((base_ref, true)) = method_table.get(name).copied() { + self.override_index + .method_overrides + .insert(*child_ref, base_ref); + self.override_index + .method_overridden_by + .entry(base_ref) + .or_default() + .push(*child_ref); + } + method_table.insert(name.clone(), (*child_ref, true)); + } else { + if let Some((base_ref, _)) = method_table.get(name).copied() { + self.override_index + .method_overrides + .insert(*child_ref, base_ref); + self.override_index + .method_overridden_by + .entry(base_ref) + .or_default() + .push(*child_ref); + } + method_table.insert(name.clone(), (*child_ref, false)); + } + } + + // Overlay this class's properties + for (name, child_ref, is_public) in &properties { + if *is_public { + if let Some((base_ref, true)) = property_table.get(name).copied() { + self.override_index + .property_overrides + .insert(*child_ref, base_ref); + self.override_index + .property_overridden_by + .entry(base_ref) + .or_default() + .push(*child_ref); + } + property_table.insert(name.clone(), (*child_ref, true)); + } else { + if let Some((base_ref, _)) = property_table.get(name).copied() { + self.override_index + .property_overrides + .insert(*child_ref, base_ref); + self.override_index + .property_overridden_by + .entry(base_ref) + .or_default() + .push(*child_ref); + } + property_table.insert(name.clone(), (*child_ref, false)); + } + } - // 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; + // Store effective tables (all methods/properties/parameters this class has access to) + let mut effective_methods: HashMap = HashMap::new(); + for (method_name, (method_ref, _)) in method_table { + effective_methods.insert(method_name.clone(), method_ref); + self.resolve_unresolved_method( + &(class_name.clone(), method_name.clone()), + method_ref, + ); + if method_ref.class != class_id { + extended_methods + .entry(class_name.clone()) + .or_insert(HashMap::new()) + .insert(method_name, method_ref); + } + } + self.override_index + .effective_methods + .insert(class_name.clone(), effective_methods); + + let mut effective_properties: HashMap = HashMap::new(); + for (property_name, (property_ref, _)) in property_table { + effective_properties.insert(property_name.clone(), property_ref); + if property_ref.class != class_id { + extended_properties + .entry(class_name.clone()) + .or_insert(HashMap::new()) + .insert(property_name, property_ref); } - 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); } + self.override_index + .effective_properties + .insert(class_name.clone(), effective_properties); - // 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); + let mut effective_parameters: HashMap = HashMap::new(); + for (parameter_name, (parameter_ref, _)) in parameter_table { + effective_parameters.insert(parameter_name.clone(), parameter_ref); + if parameter_ref.class != class_id { + extended_parameters + .entry(class_name.clone()) + .or_insert(HashMap::new()) + .insert(parameter_name, parameter_ref); } } + self.override_index + .effective_parameters + .insert(class_name.clone(), effective_parameters); + } + (extended_methods, extended_properties, extended_parameters) + } + + pub fn build_override_index_for_property( + &mut self, + affected_classes: &HashSet, + property_name: &str, + ) -> HashMap> { + let mut extended_properties = HashMap::new(); + let mut cls_name_to_id = HashMap::new(); + let mut cls_id_to_name = HashMap::new(); + for class_id in affected_classes { + if let Some(class_name) = self + .global_semantic_model + .get_class(class_id) + .map(|c| c.name.clone()) + { + cls_name_to_id.insert(class_name.clone(), *class_id); + cls_id_to_name.insert(*class_id, class_name); + } + } + // 1. Clear old entries for this method name in affected classes + for &class_id in affected_classes { + let Some(class_name) = cls_id_to_name.get(&class_id) else { + continue; + }; + // Remove from effective tables + if let Some(properties) = self.override_index.effective_properties.get_mut(class_name) { + properties.remove(property_name); + } - indices_to_exclude = self - .classes - .values() - .filter(|&class_id| { - class_id != &document_class_id && !changed_class_ids.contains(&class_id.0) + // Remove property_overrides where child belongs to affected class and has this name + let overrides_to_remove: Vec = self + .override_index + .property_overrides + .keys() + .filter(|child_ref| { + child_ref.class == class_id + && self + .global_semantic_model + .get_class(&class_id) + .and_then(|c| { + c.properties + .iter() + .find(|(_, pref)| **pref == **child_ref) + .map(|(name, _)| name == property_name) + }) + .unwrap_or(false) }) - .map(|class_id| class_id.0) + .copied() .collect(); - } else { - let urls: Vec = self - .documents + for child_ref in &overrides_to_remove { + if let Some(parent_ref) = self.override_index.property_overrides.remove(child_ref) { + if let Some(children) = self + .override_index + .property_overridden_by + .get_mut(&parent_ref) + { + children.retain(|c| c != child_ref); + } + } + } + + // Remove property_overridden_by where parent belongs to affected class with this name + let overridden_by_to_remove: Vec = self + .override_index + .property_overridden_by .keys() - .cloned() - .into_iter() - .filter(|url| !exclude.contains(url)) + .filter(|parent_ref| { + parent_ref.class == class_id + && self + .global_semantic_model + .get_class(&class_id) + .and_then(|c| { + c.properties + .iter() + .find(|(_, pref)| **pref == **parent_ref) + .map(|(name, _)| name == property_name) + }) + .unwrap_or(false) + }) + .copied() .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 parent_ref in &overridden_by_to_remove { + if let Some(children) = self + .override_index + .property_overridden_by + .remove(parent_ref) + { + for child_ref in children { + self.override_index.property_overrides.remove(&child_ref); + } } } - 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); + } + + // 2. Topological sort (same Kahn's algorithm as full rebuild) + let mut in_degree: HashMap = affected_classes + .iter() + .map(|&class_id| { + let dep_count = self + .global_semantic_model + .get_class(&class_id) + .map(|c| { + c.inherited_classes + .iter() + .filter(|(parent_name, _)| cls_name_to_id.contains_key(parent_name)) + .count() + }) + .unwrap_or(0); + (class_id, dep_count) + }) + .collect(); + + let mut queue: std::collections::VecDeque = in_degree + .iter() + .filter(|(_, deg)| **deg == 0) + .map(|(cid, _)| *cid) + .collect(); + + let mut ordered: Vec = Vec::with_capacity(affected_classes.len()); + while let Some(cid) = queue.pop_front() { + ordered.push(cid); + if let Some(dependents) = self.dependent_class_index.direct_subclasses.get(&cid) { + for child in dependents.keys() { + if let Some(deg) = in_degree.get_mut(child) { + *deg = deg.saturating_sub(1); + if *deg == 0 { + queue.push_back(*child); + } + } } } } - // 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) { + // 3. Rebuild just this method's entry for each affected class in order + for class_id in ordered { + let Some(class) = self.global_semantic_model.get_class(&class_id) else { 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 + let class_name = class.name.clone(); + let inheritance_direction = class.inheritance_direction.clone(); + let parents = class.inherited_classes.clone(); + let own_property: Option<(PropertyRef, bool)> = + class.properties.get(property_name).map(|pref| { + let is_public = self.global_semantic_model.get_property(pref).is_some(); + (*pref, is_public) + }); + + // Find inherited entry for this method name from parents + let mut inherited_entry: Option<(PropertyRef, bool)> = None; + + let parent_names: Vec = if let Some(inheritance_direction) = + inheritance_direction + && inheritance_direction == "right" + { + parents.iter().rev().map(|(name, _)| name.clone()).collect() } else { - false + parents.iter().map(|(name, _)| name.clone()).collect() }; - let language: TsLanguage; - if is_rtn { - language = LANGUAGE_OBJECTSCRIPT_ROUTINE.into(); - } else { - language = LANGUAGE_OBJECTSCRIPT_UDL.into(); + + for parent_name in &parent_names { + if let Some(parent_properties) = + self.override_index.effective_properties.get(parent_name) + { + if let Some(&parent_pref) = parent_properties.get(property_name) { + inherited_entry = Some((parent_pref, true)); + break; // first-wins semantics + } + } + } + + // Determine the effective entry and record overrides + let effective: Option<(PropertyRef, bool)> = match (own_property, inherited_entry) { + (Some((child_ref, is_public)), Some((base_ref, _))) => { + // Child overrides parent + self.override_index + .property_overrides + .insert(child_ref, base_ref); + self.override_index + .property_overridden_by + .entry(base_ref) + .or_default() + .push(child_ref); + Some((child_ref, is_public)) + } + (Some((child_ref, is_public)), None) => Some((child_ref, is_public)), + (None, Some(inherited)) => Some(inherited), + (None, None) => None, }; - // ---------- 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()); + + // Update effective table + if let Some((pref, _)) = effective { + self.override_index + .effective_properties + .entry(class_name.clone()) + .or_default() + .insert(property_name.to_string(), pref); + if pref.class != class_id { + extended_properties + .entry(class_name) + .or_insert(HashMap::new()) + .insert(property_name.to_string(), pref); } + } + } + return extended_properties; + } - 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; - }; + pub fn build_override_index_for_parameter( + &mut self, + affected_classes: &HashSet, + parameter_name: &str, + ) -> HashMap> { + let mut extended_parameters = HashMap::new(); + let mut cls_name_to_id = HashMap::new(); + let mut cls_id_to_name = HashMap::new(); + for class_id in affected_classes { + if let Some(class_name) = self + .global_semantic_model + .get_class(class_id) + .map(|c| c.name.clone()) + { + cls_name_to_id.insert(class_name.clone(), *class_id); + cls_id_to_name.insert(*class_id, class_name); + } + } + // 1. Clear old entries for this parameter name in affected classes + for &class_id in affected_classes { + let Some(class_name) = cls_id_to_name.get(&class_id) else { + continue; + }; + // Remove from effective tables + if let Some(parameters) = self.override_index.effective_parameters.get_mut(class_name) { + parameters.remove(parameter_name); + } - let Some(method_definition_node) = - tree_root_node.named_descendant_for_byte_range(loc.start_byte, loc.end_byte) - else { - continue; - }; + // Remove parameter_overrides where child belongs to affected class and has this name + let overrides_to_remove: Vec = self + .override_index + .parameter_overrides + .keys() + .filter(|child_ref| { + child_ref.class == class_id + && self + .global_semantic_model + .get_class(&class_id) + .and_then(|c| { + c.parameters + .iter() + .find(|(_, param_ref)| **param_ref == **child_ref) + .map(|(name, _)| name == parameter_name) + }) + .unwrap_or(false) + }) + .copied() + .collect(); + for child_ref in &overrides_to_remove { + if let Some(parent_ref) = self.override_index.parameter_overrides.remove(child_ref) + { + if let Some(children) = self + .override_index + .parameter_overridden_by + .get_mut(&parent_ref) + { + children.retain(|c| c != child_ref); + } + } + } + // Remove parameter_overridden_by where parent belongs to affected class with this name + let overridden_by_to_remove: Vec = self + .override_index + .parameter_overridden_by + .keys() + .filter(|parent_ref| { + parent_ref.class == class_id + && self + .global_semantic_model + .get_class(&class_id) + .and_then(|c| { + c.parameters + .iter() + .find(|(_, param_ref)| **param_ref == **parent_ref) + .map(|(name, _)| name == parameter_name) + }) + .unwrap_or(false) + }) + .copied() + .collect(); + for parent_ref in &overridden_by_to_remove { + if let Some(children) = self + .override_index + .parameter_overridden_by + .remove(parent_ref) { - self.find_method_dependencies( - method_definition_node, - content, - &language, - class_name.as_str(), - &method_ref, - ); + for child_ref in children { + self.override_index.parameter_overrides.remove(&child_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_variables(method_definition_node, content, is_rtn) - }; + // 2. Topological sort (same Kahn's algorithm as full rebuild) + let mut in_degree: HashMap = affected_classes + .iter() + .map(|&class_id| { + let dep_count = self + .global_semantic_model + .get_class(&class_id) + .map(|c| { + c.inherited_classes + .iter() + .filter(|(parent_name, _)| cls_name_to_id.contains_key(parent_name)) + .count() + }) + .unwrap_or(0); + (class_id, dep_count) + }) + .collect(); - for (variable, variable_range, refs_to_other_vars) in var_results { - let var_name = variable.name.clone(); - let var_is_public = variable.is_public; + let mut queue: std::collections::VecDeque = in_degree + .iter() + .filter(|(_, deg)| **deg == 0) + .map(|(cid, _)| *cid) + .collect(); - if refs_to_other_vars.contains(&var_name) { - continue; - } - let Some(scope_id) = - scope_tree_snapshot.find_current_scope(variable_range.start_point) - else { - eprintln!( - "Error: failed to find scope for variable range, skipping (build_inheritance_and_variables)" - ); - continue; - }; - if var_is_public { - let variable_ref = self - .global_semantic_model - .new_variable(variable, method_ref, scope_id); - { - 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, scope_id)); + let mut ordered: Vec = Vec::with_capacity(affected_classes.len()); + while let Some(cid) = queue.pop_front() { + ordered.push(cid); + if let Some(dependents) = self.dependent_class_index.direct_subclasses.get(&cid) { + for child in dependents.keys() { + if let Some(deg) = in_degree.get_mut(child) { + *deg = deg.saturating_sub(1); + if *deg == 0 { + queue.push_back(*child); } + } + } + } + } - self.global_semantic_model.new_variable_symbol( - variable_range, - url.clone(), - refs_to_other_vars.clone(), - method_ref, - variable_ref, - scope_id, - ); + // 3. Rebuild just this method's entry for each affected class in order + for class_id in ordered { + let Some(class) = self.global_semantic_model.get_class(&class_id) else { + continue; + }; - { - 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(HashMap::new) - .entry(scope_id) - .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, scope_id) - }; + let class_name = class.name.clone(); + let inheritance_direction = class.inheritance_direction.clone(); + let parents = class.inherited_classes.clone(); + let own_parameter: Option<(ParameterRef, bool)> = + class.parameters.get(parameter_name).map(|pref| { + let is_public = self.global_semantic_model.get_parameter(pref).is_some(); + (*pref, is_public) + }); - { - 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, scope_id)); - } + // Find inherited entry for this method name from parents + let mut inherited_entry: Option<(ParameterRef, bool)> = None; - { - 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, - ); - } + let parent_names: Vec = if let Some(inheritance_direction) = + inheritance_direction + && inheritance_direction == "right" + { + parents.iter().rev().map(|(name, _)| name.clone()).collect() + } else { + parents.iter().map(|(name, _)| name.clone()).collect() + }; + + for parent_name in &parent_names { + if let Some(parent_parameters) = + self.override_index.effective_parameters.get(parent_name) + { + if let Some(&parent_paramref) = parent_parameters.get(parameter_name) { + inherited_entry = Some((parent_paramref, true)); + break; // first-wins semantics } } } + + // Determine the effective entry and record overrides + let effective: Option<(ParameterRef, bool)> = match (own_parameter, inherited_entry) { + (Some((child_ref, is_public)), Some((base_ref, _))) => { + // Child overrides parent + self.override_index + .parameter_overrides + .insert(child_ref, base_ref); + self.override_index + .parameter_overridden_by + .entry(base_ref) + .or_default() + .push(child_ref); + Some((child_ref, is_public)) + } + (Some((child_ref, is_public)), None) => Some((child_ref, is_public)), + (None, Some(inherited)) => Some(inherited), + (None, None) => None, + }; + + // Update effective table + if let Some((paramref, _)) = effective { + self.override_index + .effective_parameters + .entry(class_name.clone()) + .or_default() + .insert(parameter_name.to_string(), paramref); + if paramref.class != class_id { + extended_parameters + .entry(class_name) + .or_insert(HashMap::new()) + .insert(parameter_name.to_string(), paramref); + } + } } + return extended_parameters; } - /// 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); - } + /// Rebuilds override index entries for a single method name across a set of affected classes. + /// Only touches override entries where the method name matches — much cheaper than + /// `build_override_index_for_classes` when a single method was added/removed/renamed. + /// TODO: Does this rebuild everything over and over again if I include the classid dependents? + pub fn build_override_index_for_method( + &mut self, + affected_classes: &HashSet, + method_name: &str, + ) -> HashMap> { + let mut extended_methods = HashMap::new(); + let mut cls_name_to_id = HashMap::new(); + let mut cls_id_to_name = HashMap::new(); + for class_id in affected_classes { + if let Some(class_name) = self + .global_semantic_model + .get_class(class_id) + .map(|c| c.name.clone()) + { + cls_name_to_id.insert(class_name.clone(), *class_id); + cls_id_to_name.insert(*class_id, class_name); + } + } + // 1. Clear old entries for this method name in affected classes + for &class_id in affected_classes { + let Some(class_name) = cls_id_to_name.get(&class_id) else { + continue; + }; + // Remove from effective tables + if let Some(methods) = self.override_index.effective_methods.get_mut(class_name) { + methods.remove(method_name); + } + + // Remove method_overrides where child belongs to affected class and has this name + let overrides_to_remove: Vec = self + .override_index + .method_overrides + .keys() + .filter(|child_ref| { + child_ref.class == class_id + && self + .global_semantic_model + .get_class(&class_id) + .and_then(|c| { + c.methods + .iter() + .find(|(_, mref)| **mref == **child_ref) + .map(|(name, _)| name == method_name) + }) + .unwrap_or(false) + }) + .copied() + .collect(); + for child_ref in &overrides_to_remove { + if let Some(parent_ref) = self.override_index.method_overrides.remove(child_ref) { + if let Some(children) = self + .override_index + .method_overridden_by + .get_mut(&parent_ref) + { + children.retain(|c| c != child_ref); + } + } + } + + // Remove method_overridden_by where parent belongs to affected class with this name + let overridden_by_to_remove: Vec = self + .override_index + .method_overridden_by + .keys() + .filter(|parent_ref| { + parent_ref.class == class_id + && self + .global_semantic_model + .get_class(&class_id) + .and_then(|c| { + c.methods + .iter() + .find(|(_, mref)| **mref == **parent_ref) + .map(|(name, _)| name == method_name) + }) + .unwrap_or(false) + }) + .copied() + .collect(); + for parent_ref in &overridden_by_to_remove { + if let Some(children) = self.override_index.method_overridden_by.remove(parent_ref) + { + for child_ref in children { + self.override_index.method_overrides.remove(&child_ref); } } } } - if let Some(class) = self.global_semantic_model.classes.get_mut(class_id) { - class.imports = imports; + + // 2. Topological sort (same Kahn's algorithm as full rebuild) + let mut in_degree: HashMap = affected_classes + .iter() + .map(|&class_id| { + let dep_count = self + .global_semantic_model + .get_class(&class_id) + .map(|c| { + c.inherited_classes + .iter() + .filter(|(parent_name, _)| cls_name_to_id.contains_key(parent_name)) + .count() + }) + .unwrap_or(0); + (class_id, dep_count) + }) + .collect(); + + let mut queue: std::collections::VecDeque = in_degree + .iter() + .filter(|(_, deg)| **deg == 0) + .map(|(cid, _)| *cid) + .collect(); + let mut ordered: Vec = Vec::with_capacity(affected_classes.len()); + while let Some(cid) = queue.pop_front() { + ordered.push(cid); + if let Some(dependents) = self.dependent_class_index.direct_subclasses.get(&cid) { + for child in dependents.keys() { + if let Some(deg) = in_degree.get_mut(child) { + *deg = deg.saturating_sub(1); + if *deg == 0 { + queue.push_back(*child); + } + } + } + } } - } + // 3. Rebuild just this method's entry for each affected class in order + for class_id in ordered { + let Some(class) = self.global_semantic_model.get_class(&class_id) else { + continue; + }; - /// 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 class_name = class.name.clone(); + let inheritance_direction = class.inheritance_direction.clone(); + let parents = class.inherited_classes.clone(); + let own_method: Option<(MethodRef, bool)> = + class.methods.get(method_name).map(|mref| { + let is_public = self.global_semantic_model.get_method(mref).is_some(); + (*mref, is_public) + }); + // Find inherited entry for this method name from parents + let mut inherited_entry: Option<(MethodRef, bool)> = None; - 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; - }; + let parent_names: Vec = if let Some(inheritance_direction) = + inheritance_direction + && inheritance_direction == "right" + { + parents.iter().rev().map(|(name, _)| name.clone()).collect() + } else { + parents.iter().map(|(name, _)| name.clone()).collect() + }; - 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); + for parent_name in &parent_names { + if let Some(parent_methods) = self.override_index.effective_methods.get(parent_name) + { + if let Some(&parent_mref) = parent_methods.get(method_name) { + inherited_entry = Some((parent_mref, true)); + break; // first-wins semantics } } } + + // Determine the effective entry and record overrides + let effective: Option<(MethodRef, bool)> = match (own_method, inherited_entry) { + (Some((child_ref, is_public)), Some((base_ref, _))) => { + // Child overrides parent + self.override_index + .method_overrides + .insert(child_ref, base_ref); + self.override_index + .method_overridden_by + .entry(base_ref) + .or_default() + .push(child_ref); + Some((child_ref, is_public)) + } + (Some((child_ref, is_public)), None) => Some((child_ref, is_public)), + (None, Some(inherited)) => Some(inherited), + (None, None) => None, + }; + + // Update effective table + if let Some((mref, _)) = effective { + self.override_index + .effective_methods + .entry(class_name.clone()) + .or_default() + .insert(method_name.to_string(), mref); + self.resolve_unresolved_method( + &(class_name.clone(), method_name.to_string()), + mref, + ); + if mref.class != class_id { + extended_methods + .entry(class_name) + .or_insert(HashMap::new()) + .insert(method_name.to_string(), mref); + } + } } - if let Some(class) = self.global_semantic_model.classes.get_mut(class_id) { - class.inherited_classes = inherited; - } + return extended_methods; } /// Fetch a tracked document by URL. @@ -1103,31 +3868,56 @@ impl ProjectData { /// /// 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) + self.documents.get_mut(url) } - /// 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; - }; + /// Returns the `Url` and `Range` that point to the parameter location for `parameter_name` + pub fn get_parameter_definition(&self, parameter_ref: &ParameterRef) -> Vec<(Url, Range)> { + let mut locations = Vec::new(); + if let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(¶meter_ref.class) + && let Some(parameter_symbol) = self + .global_semantic_model + .get_parameter_symbol(parameter_ref) + { + locations.push((cls_sym.url.clone(), parameter_symbol.location)); + return locations; + } else { + eprintln!( + "Error: failed to find either class symbol/document/parameter definition, aborting (get_parameter_definition)" + ); + } + return locations; + } + + /// Returns the `Url` and `Range` that point to the property location for `property_name` + pub fn get_property_definition(&self, property_ref: &PropertyRef) -> Vec<(Url, Range)> { + let mut locations = Vec::new(); + if let Some(cls_sym) = self + .global_semantic_model + .get_class_symbol(&property_ref.class) + && let Some(cls_doc) = self.get_document(&cls_sym.url) + { + if let Some(property_symbol) = + self.global_semantic_model.get_property_symbol(property_ref) + { + locations.push((cls_sym.url.clone(), property_symbol.location)); + return locations; + } else if let Some(property_symbol) = cls_doc + .scope_tree + .get_private_property_symbol(&property_ref) + { + locations.push((cls_sym.url.clone(), property_symbol.location)); + return locations; + } + } else { + eprintln!( + "Error: failed to find class symbol or document, aborting (get_property_definition)" + ); + } - self.global_semantic_model.get_method_symbol(&sym_id) + return locations; } /// Returns the `Url` and `Range` that point to the method location for `method_name` @@ -1143,10 +3933,27 @@ impl ProjectData { && 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)); + 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_range = Range { + start_byte: new_start_byte, + start_point: new_start_point, + end_byte: new_start_byte, + end_point: new_start_point, + }; + new_range + } else { + method_symbol.location + }; + locations.push((cls_sym.url.clone(), sym_range)); return locations; } else if let Some(method_symbol) = - cls_doc.scope_tree.get_private_method_symbol(&method_ref.id) + cls_doc.scope_tree.get_private_method_symbol(&method_ref) { let sym_range = if let Some(offset) = offset { let content = &cls_doc.content; @@ -1155,16 +3962,11 @@ impl ProjectData { 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, + end_byte: new_start_byte, + end_point: new_start_point, }; new_range } else { @@ -1173,11 +3975,86 @@ impl ProjectData { locations.push((cls_sym.url.clone(), sym_range)); return locations; } + } else { + eprintln!( + "Error: failed to find class symbol or document, aborting (get_method_definition" + ); } return locations; } + fn public_variable_locations_before_call( + &self, + public_var_definitions: &HashMap>>, + ancestor_ref: &MethodRef, + method_call_range: &Range, + ) -> Vec<(Url, Range)> { + let mut locations = Vec::new(); + let Some(variable_refs_hash_map) = public_var_definitions.get(ancestor_ref) else { + return locations; + }; + let Some(def_cls_sym) = self + .global_semantic_model + .get_class_symbol(&ancestor_ref.class) + else { + return locations; + }; + let Some(def_doc) = self.get_document(&def_cls_sym.url) else { + return locations; + }; + let Some(method_scope_id) = def_doc + .scope_tree + .find_current_scope(method_call_range.start_point) + else { + return locations; + }; + + let mut location_hash = HashMap::new(); + let mut seen_scope_ids = Vec::new(); + let mut scope_children = def_doc.scope_tree.get_scope_children(&method_scope_id); + scope_children.insert(method_scope_id); + for (child_scope_id, variable_refs) in variable_refs_hash_map { + if !scope_children.contains(child_scope_id) { + continue; + } + 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( + ancestor_ref, + var_id.0, + child_scope_id, + ) + && symbol.location.end_byte < method_call_range.start_byte + { + if !seen_scope_ids.contains(child_scope_id) { + let index = locations.len(); + locations.push((symbol.url.clone(), symbol.location)); + seen_scope_ids.push(*child_scope_id); + location_hash.insert(child_scope_id, index); + } else if let Some(&index) = location_hash.get(child_scope_id) { + let curr_indexed_sym_range = locations[index].1; + if curr_indexed_sym_range.end_byte < symbol.location.start_byte { + locations[index] = (symbol.url.clone(), symbol.location); + } + } + } + } + } + locations + } + + fn push_unique_location(locations: &mut Vec<(Url, Range)>, location: (Url, Range)) { + let already_seen = locations.iter().any(|(url, range)| { + url == &location.0 + && range.start_byte == location.1.start_byte + && range.end_byte == location.1.end_byte + }); + if !already_seen { + locations.push(location); + } + } + /// Finds all potential variable definitons for `variable_name` /// and finds the corresponding variables If there is another variable definition in the same scope /// and it comes after the first definition (but before either point or method_call based on the case), @@ -1202,9 +4079,7 @@ impl ProjectData { return locations; }; - let Some(class_name) = document.class_name.clone() else { - return locations; - }; + let class_name = document.class_name.clone(); let Some(method_name) = document.scope_tree.get_method_name(point) else { return locations; @@ -1228,7 +4103,7 @@ impl ProjectData { let mut location_hash = HashMap::new(); let mut seen_scope_ids = Vec::new(); let mut scope_children = document.scope_tree.get_scope_children(&var_ref_scope_id); - scope_children.push(var_ref_scope_id); + scope_children.insert(var_ref_scope_id); for (child_scope_id, variable_ranges) in private_var_ranges { if !scope_children.contains(&child_scope_id) { continue; @@ -1260,7 +4135,7 @@ impl ProjectData { let mut location_hash = HashMap::new(); let mut seen_scope_ids = Vec::new(); let mut scope_children = document.scope_tree.get_scope_children(&var_ref_scope_id); - scope_children.push(var_ref_scope_id); + scope_children.insert(var_ref_scope_id); for (child_scope_id, variable_refs) in pub_var_refs { if !scope_children.contains(&child_scope_id) { continue; @@ -1297,76 +4172,25 @@ impl ProjectData { 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_ancestors = self.dependency_graph.all_ancestors(node_index); - let mut found_depth: Option = None; - for (ancestor_ref, method_call_range, depth) in &all_ancestors { - if let Some(fd) = found_depth { - if *depth > fd { - break; - } - } - let Some(variable_refs_hash_map) = - public_var_definitions.get(ancestor_ref) - else { - continue; - }; - let Some(def_cls_sym) = self - .global_semantic_model - .get_class_symbol(&ancestor_ref.class) - else { - continue; - }; - let Some(def_doc) = self.get_document(&def_cls_sym.url) else { - continue; - }; - let Some(method_scope_id) = def_doc - .scope_tree - .find_current_scope(method_call_range.start_point) - else { - continue; - }; - let mut location_hash = HashMap::new(); - let mut seen_scope_ids = Vec::new(); - let mut scope_children = - def_doc.scope_tree.get_scope_children(&method_scope_id); - scope_children.push(method_scope_id); - for (child_scope_id, variable_refs) in variable_refs_hash_map { - if !scope_children.contains(child_scope_id) { - continue; - } - 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( - ancestor_ref, - var_id.0, - child_scope_id, - ) - { - if symbol.location.end_byte < method_call_range.start_byte { - if !seen_scope_ids.contains(child_scope_id) { - let index = locations.len(); - locations - .push((symbol.url.clone(), symbol.location)); - seen_scope_ids.push(*child_scope_id); - location_hash.insert(child_scope_id, index); - } else if let Some(&index) = - location_hash.get(child_scope_id) - { - let curr_indexed_sym_range = locations[index].1; - if curr_indexed_sym_range.end_byte - < symbol.location.start_byte - { - locations[index] = - (symbol.url.clone(), symbol.location); - } - } - } - } - } - } - if !locations.is_empty() { - found_depth = Some(*depth); + let definers = self.dependency_graph.closest_definers( + node_index, + |ancestor_ref, method_call_range| { + !self + .public_variable_locations_before_call( + public_var_definitions, + ancestor_ref, + method_call_range, + ) + .is_empty() + }, + ); + for (ancestor_ref, method_call_range) in definers { + for location in self.public_variable_locations_before_call( + public_var_definitions, + &ancestor_ref, + &method_call_range, + ) { + Self::push_unique_location(&mut locations, location); } } if !locations.is_empty() { @@ -1387,16 +4211,59 @@ impl ProjectData { oref_ref_range: Range, resolve_method: bool, ) -> Vec<(Url, Range)> { - let (method_refs, locations) = - self.find_classes_from_oref(oref_name, oref_method_name, curr_class, oref_ref_range); + let curr_class_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.clone() + } else { + return Vec::new(); + }; + let Some(current_document) = self.get_document(&curr_class_url) else { + return Vec::new(); + }; + let Some(curr_method_name) = current_document + .scope_tree + .get_method_name(oref_ref_range.start_point) + else { + return Vec::new(); + }; + let Some(current_method_ref) = self + .method_defs + .get(curr_class) + .and_then(|method_refs| method_refs.get(&curr_method_name)) + .copied() + else { + return Vec::new(); + }; + let scope_tree = current_document.scope_tree.clone(); + + let (resolved, _) = self.resolve_oref_methods( + current_method_ref, + oref_name, + oref_method_name, + oref_ref_range, + &curr_method_name, + &scope_tree, + ); + if !resolve_method { - return locations; - } - let mut oref_method_locations = Vec::new(); - for (method_ref, _) in method_refs { - oref_method_locations.extend(self.get_method_definition(&method_ref, None)) + // Return the locations of the oref variable definitions (not the method they point to) + // For now, return class definition of the resolved oref classes + let mut locations = Vec::new(); + for method_ref in &resolved { + let class_id = method_ref.class; + if let Some(class_sym) = self.global_semantic_model.get_class_symbol(&class_id) { + locations.push((class_sym.url.clone(), class_sym.location)); + } + } + locations + } else { + let mut oref_method_locations = Vec::new(); + for method_ref in &resolved { + oref_method_locations.extend(self.get_method_definition(method_ref, None)); + } + oref_method_locations } - oref_method_locations } /// Finds the method struct representing the method that `variable_name` is defined in @@ -1499,21 +4366,27 @@ impl ProjectData { locations } - /// Returns the location of the superclass method that the given subclass method overrides + /// Returns the location of the superclass method that the given subclass method method_overrides pub fn get_method_superclass( &self, method_name: String, class_id: &ClassId, + url: &Url, ) -> Vec<(Url, Range)> { let method_name_str = method_name.as_str(); + let mut locations = Vec::new(); + let Some(document) = self.get_document(url) else { + return locations; + }; 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) { + let superclass_method_ref = match self.override_index.method_overrides.get(&method_ref) + { Some(v) => v, None => { eprintln!( @@ -1532,6 +4405,109 @@ impl ProjectData { superclass_method_symbol.url.clone(), superclass_method_symbol.location, )); + } else if let Some(superclass_method_symbol) = document + .scope_tree + .get_private_method_symbol(superclass_method_ref) + { + locations.push(( + superclass_method_symbol.url.clone(), + superclass_method_symbol.location, + )); + } + } + return locations; + } + + /// Returns the location of the superclass parameter that the given subclass parameter parameter_overrides + pub fn get_parameter_superclass( + &self, + parameter_name: String, + class_id: &ClassId, + ) -> Vec<(Url, Range)> { + let parameter_name_str = parameter_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_parameter_superclass)",); + return locations; + }; + if let Some(parameter_ref) = class.parameters.get(parameter_name_str) { + let superclass_parameter_ref = match self + .override_index + .parameter_overrides + .get(parameter_ref) + { + Some(v) => v, + None => { + eprintln!( + "Info: Parameter {:?} in subclass {:?} doesn't override any superclass parameter", + parameter_name_str, class.name + ); + return locations; + } + }; + + if let Some(superclass_parameter_symbol) = self + .global_semantic_model + .get_parameter_symbol(superclass_parameter_ref) + { + locations.push(( + superclass_parameter_symbol.url.clone(), + superclass_parameter_symbol.location, + )); + } + } + return locations; + } + + /// Returns the location of the superclass property that the given subclass property property_overrides + pub fn get_property_superclass( + &self, + property_name: String, + class_id: &ClassId, + url: &Url, + ) -> Vec<(Url, Range)> { + let property_name_str = property_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_property_superclass)",); + return locations; + }; + let Some(document) = self.get_document(url) else { + return locations; + }; + + if let Some(property_ref) = class.properties.get(property_name_str) { + let superclass_property_ref = match self + .override_index + .property_overrides + .get(property_ref) + { + Some(v) => v, + None => { + eprintln!( + "Info: Property {:?} in subclass {:?} doesn't override any superclass property", + property_name_str, class.name + ); + return locations; + } + }; + + if let Some(superclass_property_symbol) = self + .global_semantic_model + .get_property_symbol(superclass_property_ref) + { + locations.push(( + superclass_property_symbol.url.clone(), + superclass_property_symbol.location, + )); + } else if let Some(superclass_property_symbol) = document + .scope_tree + .get_private_property_symbol(superclass_property_ref) + { + locations.push(( + superclass_property_symbol.url.clone(), + superclass_property_symbol.location, + )); } } return locations; @@ -1541,27 +4517,29 @@ impl ProjectData { 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 { + for (inherited_class_name, _) in &class.inherited_classes { + if let Some(inherited_class_id) = self.classes.get(inherited_class_name) + && let Some(inherited_class) = + self.global_semantic_model.get_class(inherited_class_id) + { + 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, + )) + } 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 @@ -1570,19 +4548,34 @@ impl ProjectData { /// 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. + /// `override_index.method_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)> { + pub fn get_method_overrides(&self, method_ref: &MethodRef, url: &Url) -> Vec<(Url, Range)> { let mut locations = Vec::new(); + let Some(document) = self.get_document(url) else { + eprintln!( + "Error: Document {:?} DNE, aborting (get_method_overrides)", + url.path() + ); + return locations; + }; + if document.class_id != Some(method_ref.class) { + eprintln!( + "Error: MethodRef {:?} does not belong to document {:?}, aborting (get_method_overrides)", + method_ref, + url.path() + ); + return locations; + } // ---- overridden-by list ---- - let overrides = match self.override_index.overridden_by.get(method_ref) { + let method_overrides = match self.override_index.method_overridden_by.get(method_ref) { Some(v) => v, None => { return locations; } }; - for override_method_ref in overrides { + for override_method_ref in method_overrides { let override_cls_url = if let Some(class_symbol) = self .global_semantic_model .get_class_symbol(&override_method_ref.class) @@ -1600,540 +4593,13 @@ impl ProjectData { } 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) + .get_private_method_symbol(&override_method_ref) { locations.push((override_cls_url.clone(), sym.location)); } } locations } - - /// Finds all potential variable references for `oref_name`, - /// and finds the corresponding variable and checks if it is an oref. - /// If yes, finds the method and class that the oref is referencing, and - /// creates a method ref. If there is another oref definition in the same scope - /// and it comes after the first definition, it will replace the first definition. - /// If the definition is defined in the current class and method, then if the - /// definition comes after `point` it is not added. Similarly, if the definition - /// is from another class/method (and is connected by method calls, tracked in dependencyGraph), - /// if the definition comes after the method call that connects it, it is not added. - /// - /// The first Vec returned contains: - /// - `MethodRef` - a reference to the actual method and class that the oref method call was referencing. - /// - `Range` - the oref method call node range - /// The second Vec returned contains: - /// - `Url` - the url of the associated variable definition (where the oref was created) - /// - `Range` - the range of the associated variable definition (where the oref was created) - fn find_classes_from_oref( - &self, - oref_name: &str, - oref_method_name: &str, - curr_class: &str, - original_method_call: Range, // this is a range within the current method - ) -> (Vec<(MethodRef, Range)>, Vec<(Url, Range)>) { - let point = original_method_call.start_point; - let mut oref_method_refs = Vec::new(); - let mut locations = Vec::new(); - let curr_class_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, locations); - }; - let Some(current_document) = self.get_document(curr_class_url) else { - eprintln!( - "Error: failed to get document for file {:?}. Aborting find_classes_from_oref", - curr_class_url.path() - ); - return (oref_method_refs, locations); - }; - let Some(curr_method_name) = current_document.scope_tree.get_method_name(point) else { - return (oref_method_refs, locations); - }; - - let Some(var_ref_scope_id) = current_document.scope_tree.find_current_scope(point) else { - eprintln!( - "Error: failed to find scope for variable range, returning (find_classes_from_oref)" - ); - return (oref_method_refs, locations); - }; - - if let Some(current_method_ref) = self - .method_defs - .get(curr_class) - .and_then(|method_refs| method_refs.get(&curr_method_name)) - { - let is_variable_public = - self.is_variable_public(*current_method_ref, oref_name.to_string()); - let potential_oref_refs = current_document - .scope_tree - .get_oref_references(oref_name, var_ref_scope_id); - if !potential_oref_refs.is_empty() { - let Some(lsm) = self - .global_semantic_model - .get_local_semantic(¤t_method_ref.class) - else { - return (oref_method_refs, locations); - }; - let mut location_hash = HashMap::new(); - let mut seen_scope_ids = Vec::new(); - let mut scope_children = current_document - .scope_tree - .get_scope_children(&var_ref_scope_id); - scope_children.push(var_ref_scope_id); - for (child_scope_id, variable_refs) in potential_oref_refs { - if !scope_children.contains(&child_scope_id) { - continue; - } - for variable_ref in variable_refs { - if !is_variable_public - && let Some(var_id) = variable_ref.priv_id - && let Some(variable) = - lsm.get_variable(current_method_ref, var_id.0, &child_scope_id) - && let Some(symbol) = current_document - .scope_tree - .get_variable_symbol(var_id.0, &child_scope_id) - { - if variable.is_oref - && let Some(oref_class_name) = variable.cls.clone() - && let Some(oref_method_ref) = self - .method_defs - .get(&oref_class_name) - .and_then(|methods| methods.get(oref_method_name)) - { - if symbol.location.end_point < point { - if !seen_scope_ids.contains(&child_scope_id) { - let index = oref_method_refs.len(); - oref_method_refs - .push((*oref_method_ref, original_method_call)); - locations.push((curr_class_url.clone(), symbol.location)); - seen_scope_ids.push(child_scope_id); - location_hash.insert(child_scope_id, index); - } else if let Some(&index) = location_hash.get(&child_scope_id) - { - let curr_indexed_sym_range = locations[index].1; - if curr_indexed_sym_range.end_byte - < symbol.location.start_byte - { - oref_method_refs[index] = - (*oref_method_ref, original_method_call); - locations[index] = - (curr_class_url.clone(), symbol.location); - } - } - } - } - } else if is_variable_public - && let Some(var_id) = variable_ref.pub_id - && let Some(variable) = self.global_semantic_model.get_variable( - current_method_ref, - var_id.0, - &child_scope_id, - ) - && let Some(symbol) = self.global_semantic_model.get_variable_symbol( - current_method_ref, - var_id.0, - &child_scope_id, - ) - { - if variable.is_oref - && let Some(oref_class_name) = variable.cls.clone() - && let Some(oref_method_ref) = self - .method_defs - .get(&oref_class_name) - .and_then(|methods| methods.get(oref_method_name)) - { - if symbol.location.end_point < point { - if !seen_scope_ids.contains(&child_scope_id) { - let index = oref_method_refs.len(); - oref_method_refs - .push((*oref_method_ref, original_method_call)); - locations.push((symbol.url.clone(), symbol.location)); - seen_scope_ids.push(child_scope_id); - location_hash.insert(child_scope_id, index); - } else if let Some(&index) = location_hash.get(&child_scope_id) - { - let curr_indexed_sym_range = locations[index].1; - if curr_indexed_sym_range.end_byte - < symbol.location.start_byte - { - locations[index] = - (symbol.url.clone(), symbol.location); - oref_method_refs[index] = - (*oref_method_ref, original_method_call); - } - } - } - } - } - } - } - } - if is_variable_public { - if let Some(&node_index) = self.dependency_graph.get_node(*current_method_ref) - && let Some(public_var_definitions) = self.pub_var_defs.get(oref_name) - { - let all_ancestors = self.dependency_graph.all_ancestors(node_index); - let mut found_depth: Option = None; - for (ancestor_ref, method_call_range, depth) in &all_ancestors { - if let Some(fd) = found_depth { - if *depth > fd { - break; - } - } - let Some(variable_refs_hash_map) = - public_var_definitions.get(ancestor_ref) - else { - continue; - }; - let Some(def_cls_sym) = self - .global_semantic_model - .get_class_symbol(&ancestor_ref.class) - else { - continue; - }; - let Some(def_doc) = self.get_document(&def_cls_sym.url) else { - continue; - }; - let Some(method_scope_id) = def_doc - .scope_tree - .find_current_scope(method_call_range.start_point) - else { - continue; - }; - let mut location_hash = HashMap::new(); - let mut seen_scope_ids = Vec::new(); - let mut scope_children = - def_doc.scope_tree.get_scope_children(&method_scope_id); - scope_children.push(method_scope_id); - for (child_scope_id, variable_refs) in variable_refs_hash_map { - if !scope_children.contains(child_scope_id) { - continue; - } - 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( - ancestor_ref, - var_id.0, - child_scope_id, - ) - && let Some(variable) = self - .global_semantic_model - .get_variable(ancestor_ref, var_id.0, child_scope_id) - && variable.is_oref - && let Some(oref_class_name) = variable.cls.clone() - && let Some(oref_method_ref) = self - .method_defs - .get(&oref_class_name) - .and_then(|methods| methods.get(oref_method_name)) - { - if symbol.location.end_byte < method_call_range.start_byte { - if !seen_scope_ids.contains(child_scope_id) { - let index = oref_method_refs.len(); - oref_method_refs - .push((*oref_method_ref, original_method_call)); - locations - .push((symbol.url.clone(), symbol.location)); - seen_scope_ids.push(*child_scope_id); - location_hash.insert(child_scope_id, index); - } else if let Some(&index) = - location_hash.get(child_scope_id) - { - let curr_indexed_sym_range = locations[index].1; - if curr_indexed_sym_range.end_byte - < symbol.location.start_byte - { - locations[index] = - (symbol.url.clone(), symbol.location); - oref_method_refs[index] = - (*oref_method_ref, original_method_call); - } - } - } - } - } - } - if !oref_method_refs.is_empty() { - found_depth = Some(*depth); - } - } - if !oref_method_refs.is_empty() { - return (oref_method_refs, locations); - } - } - } - } - return (oref_method_refs, locations); - } - - fn get_method_calls( - &self, - method_def_node: Node, - content: &str, - language: &TsLanguage, - query_str: &str, - curr_class: &str, - ) -> Vec<(MethodRef, Range)> { - 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, matched_node.range())); - } - } - } - } 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.range(), - ) - .0, - ); - } 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, matched_node.range())); - } - } - } 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, matched_node.range())); - } - } - } - } - } - "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, matched_node.range())); - } - } - } - "routine_tag_call" | "goto_argument" | "print_argument" => { - 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, matched_node.range())); - } - } - "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, matched_node.range())); - } - } - _ => 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) - ] @routine "#; - method_refs.extend(self.get_method_calls(node, content, language, query_str, curr_class)); - for (dep_method_ref, method_call_range) in method_refs { - self.dependency_graph - .add_edge(method_ref.clone(), dep_method_ref, method_call_range); - } - } } impl ProjectState { @@ -2152,9 +4618,16 @@ impl ProjectState { classes: HashMap::new(), method_defs: HashMap::new(), pub_var_defs: HashMap::new(), + parameter_defs: HashMap::new(), + property_defs: HashMap::new(), override_index: OverrideIndex::new(), dependent_class_index: Dependents::new(), dependency_graph: DependencyGraph::new(), + unresolved_method_references: HashMap::new(), + unresolved_inheritance_references: HashMap::new(), + inheritance_diagonstics: HashMap::new(), + method_reference_diagnostics: HashMap::new(), + other_class_diagnostics: HashMap::new(), }), } } @@ -2209,11 +4682,30 @@ impl ProjectState { match existing_snapshot { None => { - data.add_document(url, text, tree, file_type, None, Some(version)); + data.add_document( + url, + text.as_str(), + &tree, + file_type, + None, + "XML".to_string(), + Some(version), + tree.root_node().range(), + ); } Some((old_text, old_type)) => { if old_text != text || old_type != file_type { - data.update_document(url, tree, file_type, version, &text); + data.incremental_update_document( + url, + &tree, + file_type, + version, + &text, + Vec::new(), + "XML".to_string(), + tree.root_node().range(), + tree.root_node().range(), + ); } else if let Some(doc) = data.documents.get_mut(&url) { doc.version = Some(version); } @@ -2227,7 +4719,8 @@ impl ProjectState { false }; - let Some(member_name) = get_member_name_from_root(&text, tree.root_node(), is_rtn) + let Some((class_range, class_name, class_name_def_range)) = + get_member_name_and_range_from_root(&text, tree.root_node(), is_rtn) else { eprintln!( "Error: Failed to get name from root node for file url: {:?}", @@ -2237,7 +4730,6 @@ impl ProjectState { }; // Commit INSIDE one lock let mut data = self.data.write(); - let existing_snapshot = data .documents .get(&url) @@ -2245,20 +4737,56 @@ impl ProjectState { match existing_snapshot { None => { + if data.classes.contains_key(&class_name) { + let lsp_range = ts_range_to_lsp_range(&text, class_name_def_range); + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::ERROR), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "A Class named {:?} already exists in this workspace.", + &class_name + ), + related_information: None, + tags: None, + data: None, + }; + data.other_class_diagnostics + .entry(url.clone()) + .or_insert(Vec::new()) + .push(diagnostic); + return; + } + let class_id = data.global_semantic_model.next_id(); + data.add_document( url.clone(), - text, - tree, + &text, + &tree, file_type, - Some(member_name), + Some(ClassId(class_id)), + class_name, Some(version), + class_range, ); // build override index/calls/vars for new doc too - data.build_inheritance_and_variables(Some(url), Vec::new()); + // 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); + data.incremental_update_document( + url, + &tree, + file_type, + version, + &text, + Vec::new(), + class_name, + class_range, + class_name_def_range, + ); } else { if let Some(doc) = data.documents.get_mut(&url) { doc.version = Some(version); @@ -2278,24 +4806,112 @@ impl ProjectState { pub fn update_document( &self, url: Url, - tree: Tree, + tree: &Tree, file_type: FileType, version: i32, content: &str, + changed_ranges: Vec, ) { - self.data - .write() - .update_document(url, tree, file_type, version, content); + let is_rtn = if file_type == FileType::Routine { + true + } else { + false + }; + let (class_range, class_name, class_name_def_range) = if file_type == FileType::Xml { + ( + tree.root_node().range(), + "XML".to_string(), + tree.root_node().range(), + ) + } else { + if let Some((class_range, class_name, class_name_def_range)) = + get_member_name_and_range_from_root(content, tree.root_node(), is_rtn) + { + (class_range, class_name, class_name_def_range) + } else { + eprintln!( + "Error: Failed to get name from root node for file url: {:?}", + url.path() + ); + return; + } + }; + self.data.write().incremental_update_document( + url, + tree, + file_type, + version, + content, + changed_ranges, + class_name, + class_range, + class_name_def_range, + ); } /// 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) + let file_type = { + let data = self.data.read(); + data.get_document(url)?.file_type.clone() + }; + let mut parser = match file_type { + FileType::Routine => self.parsers.routine.lock(), + FileType::Cls => self.parsers.cls.lock(), + FileType::Xml => return None, + }; + self.data + .read() + .refactor_document_with_parser(url, refactor_level, &mut parser) } /// 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) + let candidates: Vec<(Url, FileType)> = { + let data = self.data.read(); + data.documents + .iter() + .filter_map(|(url, document)| { + if document.file_type == FileType::Xml { + return None; + } + if refactor_level == RefactorLevel::DoCommands + && document.file_type != FileType::Routine + { + return None; + } + Some((url.clone(), document.file_type.clone())) + }) + .collect() + }; + + let mut changed = Vec::new(); + for (url, file_type) in candidates { + let refactored = match file_type { + FileType::Routine => { + let mut parser = self.parsers.routine.lock(); + self.data.read().refactor_document_with_parser( + &url, + refactor_level, + &mut parser, + ) + } + FileType::Cls => { + let mut parser = self.parsers.cls.lock(); + self.data.read().refactor_document_with_parser( + &url, + refactor_level, + &mut parser, + ) + } + FileType::Xml => None, + }; + let Some(refactored) = refactored else { + continue; + }; + changed.push((refactored, url)); + } + changed } /// Return the project root path, if initialized. diff --git a/objectscript-lsp/documentation/benchmarks/update-document.md b/objectscript-lsp/documentation/benchmarks/update-document.md new file mode 100644 index 0000000..0cd203a --- /dev/null +++ b/objectscript-lsp/documentation/benchmarks/update-document.md @@ -0,0 +1,399 @@ +# Update Document Benchmarks + +This benchmark compares the old full document update path against the new incremental document update path. It also measures Tree-sitter parsing time for the same generated file and edit. + +The benchmark target is: + +```text +crates/objectscript-core/benches/update_document.rs +``` + +It is a Criterion benchmark, so it reports statistically sampled timing ranges and writes an HTML report that can be used for presentations. + +## Quick Smoke Run + +Use this when you only want to verify that the benchmark compiles and runs. + +To compile the benchmark without collecting measurements: + +```bash +cargo bench -p objectscript-core --features update-bench --bench update_document --no-run +``` + +```bash +BENCH_METHODS=10 \ +BENCH_BODY_LINES=1 \ +BENCH_SAMPLE_SIZE=10 \ +BENCH_WARMUP_SECS=1 \ +BENCH_MEASUREMENT_SECS=1 \ +cargo bench -p objectscript-core --features update-bench --bench update_document -- --quiet +``` + +The smoke run is intentionally small. Do not use it as the final presentation number. + +## Presentation Run + +Use a larger synthetic class and longer measurement window for numbers that are more stable. + +```bash +BENCH_METHODS=100 \ +BENCH_BODY_LINES=12 \ +BENCH_SAMPLE_SIZE=10 \ +BENCH_WARMUP_SECS=3 \ +BENCH_MEASUREMENT_SECS=10 \ +cargo bench -p objectscript-core --features update-bench --bench update_document +``` + +Criterion writes the aggregate report to: + +```text +target/criterion/report/index.html +``` + +It also writes per-benchmark reports under: + +```text +target/criterion/update_document/ +target/criterion/parse_document/ +``` + +If the benchmark is run from inside `crates/objectscript-core`, Criterion may write those reports under `crates/objectscript-core/target/criterion/` instead. + +If `gnuplot` is not installed, Criterion falls back to the Rust `plotters` backend. That is fine; the benchmark still runs and still generates HTML reports. + +## Running Only One Benchmark Group + +The same Criterion target contains two groups: + +- `update_document` +- `parse_document` + +To run only the update benchmarks: + +```bash +cargo bench -p objectscript-core --features update-bench --bench update_document -- update_document +``` + +To run only the parser benchmarks: + +```bash +cargo bench -p objectscript-core --features update-bench --bench update_document -- parse_document +``` + +## Running Against a Real File + +Set `BENCH_INPUT_FILE` to benchmark one existing source file instead of a generated synthetic class. + +The local large dotted-statement routine fixture also has a named preset: + +```bash +BENCH_INPUT_PRESET=large_dotted_statements \ +BENCH_SAMPLE_SIZE=10 \ +BENCH_WARMUP_SECS=3 \ +BENCH_MEASUREMENT_SECS=10 \ +cargo bench -p objectscript-core --features update-bench --bench update_document +``` + +The preset loads: + +```text +objectscript-tests/local/test-large-dotted-statements-full.mac +``` + +For example, to run the same update and parser benchmarks against an explicit file path: + +```bash +BENCH_INPUT_FILE=/Users/hkimura/zed-objectscript/objectscript-lsp/objectscript-tests/local/test-large-dotted-statements-full.mac \ +BENCH_SAMPLE_SIZE=10 \ +BENCH_WARMUP_SECS=3 \ +BENCH_MEASUREMENT_SECS=10 \ +cargo bench -p objectscript-core --features update-bench --bench update_document +``` + +To run only the parser benchmarks for that file: + +```bash +BENCH_INPUT_PRESET=large_dotted_statements \ +BENCH_SAMPLE_SIZE=10 \ +BENCH_WARMUP_SECS=1 \ +BENCH_MEASUREMENT_SECS=1 \ +cargo bench -p objectscript-core --features update-bench --bench update_document -- parse_document --quiet +``` + +To run only the update benchmarks for that file: + +```bash +BENCH_INPUT_PRESET=large_dotted_statements \ +BENCH_SAMPLE_SIZE=10 \ +BENCH_WARMUP_SECS=1 \ +BENCH_MEASUREMENT_SECS=1 \ +cargo bench -p objectscript-core --features update-bench --bench update_document -- update_document --quiet +``` + +The real-file mode supports `.cls`, `.mac`, `.inc`, `.rtn`, and `.int` files. When `BENCH_INPUT_FILE` or `BENCH_INPUT_PRESET` is set, it overrides `BENCH_METHODS`, `BENCH_METHODS_LIST`, and `BENCH_BODY_LINES`. `BENCH_INPUT_FILE` has priority if both are set. + +The large dotted-statement fixture is currently about 87 KB: + +```text +1181 lines +87458 bytes +``` + +The real-file benchmark makes one small insertion near the middle of the file. For routine files, it inserts an ObjectScript comment line and preserves the existing leading whitespace or dotted indentation. For class files, it inserts a comment line. + +Some routine benchmarks currently print semantic-analysis warnings to stderr, such as unsupported set-target cases. Those warnings are not Criterion output. If they make the terminal hard to read, redirect stderr: + +```bash +BENCH_INPUT_FILE=/Users/hkimura/zed-objectscript/objectscript-lsp/objectscript-tests/local/test-large-dotted-statements-full.mac \ +cargo bench -p objectscript-core --features update-bench --bench update_document -- update_document --quiet \ +2>/tmp/objectscript-update-document-bench.stderr +``` + +## Scaling Run + +To show how the two paths scale as document size grows, run several method counts in one benchmark invocation: + +```bash +BENCH_METHODS_LIST=20,50,100 \ +BENCH_BODY_LINES=12 \ +BENCH_SAMPLE_SIZE=10 \ +BENCH_WARMUP_SECS=3 \ +BENCH_MEASUREMENT_SECS=10 \ +cargo bench -p objectscript-core --features update-bench --bench update_document +``` + +This produces separate `full_update_document` and `incremental_update_document` measurements for each generated class size. + +It also produces `full_parse_document` and `incremental_parse_document` measurements for the same sizes. + +## Environment Variables + +| Variable | Default | Meaning | +|---|---:|---| +| `BENCH_METHODS` | `100` | Number of methods generated in the synthetic `Bench.Big` class. Ignored when `BENCH_METHODS_LIST` is set. | +| `BENCH_METHODS_LIST` | unset | Comma-separated method counts used to benchmark multiple document sizes in one run. Example: `20,50,100`. | +| `BENCH_BODY_LINES` | `12` | Number of repeated body lines generated inside each method. | +| `BENCH_INPUT_FILE` | unset | Path to a real `.cls`, `.mac`, `.inc`, `.rtn`, or `.int` file to benchmark instead of generating a synthetic class. | +| `BENCH_INPUT_PRESET` | unset | Named real-file fixture to benchmark. Supported value: `large_dotted_statements`. Ignored when `BENCH_INPUT_FILE` is set. | +| `BENCH_SAMPLE_SIZE` | `10` | Criterion sample count. The benchmark enforces Criterion's minimum of 10. | +| `BENCH_WARMUP_SECS` | `3` | Criterion warmup duration in seconds. | +| `BENCH_MEASUREMENT_SECS` | `10` | Criterion measurement duration in seconds. | + +## What the Benchmark Builds + +The benchmark creates a synthetic ObjectScript class named `Bench.Big`. + +The generated class shape is: + +```objectscript +Class Bench.Big +{ +Method Method0() As %Status +{ + Set methodNumber = 0 + Set total = 0 + Set total = total + 0 + ... + Write total + Quit total +} + +Method Method1() As %Status +{ + ... +} +} +``` + +The class size is controlled by `BENCH_METHODS` or `BENCH_METHODS_LIST` plus `BENCH_BODY_LINES`. + +The benchmark then makes one small edit in the middle method: + +```objectscript +Set methodNumber = N +Write methodNumber +``` + +That simulates the common LSP case: a large document is already open and indexed, and the user makes a small edit inside one method body. + +When `BENCH_INPUT_FILE` is set, the benchmark uses the file contents instead of generating `Bench.Big`. It still follows the same setup: parse and index the old document, prepare one small edit, parse the new document, then time the update functions against that prepared state. + +## What Is Timed + +### Update Benchmarks + +Each Criterion iteration starts with an already-indexed old document. The timed section compares: + +```text +ProjectData::full_update_document(...) +``` + +against: + +```text +ProjectData::incremental_update_document(...) +``` + +The benchmark uses `BatchSize::PerIteration`, so each measurement gets fresh project state and does not reuse a mutated `ProjectData` from the previous iteration. + +### Parser Benchmarks + +The parser group compares: + +```text +full_parse_document +``` + +against: + +```text +incremental_parse_document +``` + +`full_parse_document` measures parsing the updated document text from scratch: + +```text +Parser::parse(new_content, None) +``` + +`incremental_parse_document` measures Tree-sitter incremental parsing using the edited old tree: + +```text +Parser::parse(new_content, Some(edited_old_tree)) +``` + +Both parser benchmarks exclude parser construction and language setup. The incremental parser benchmark also excludes cloning the old tree and applying `Tree::edit`; those happen in Criterion setup so the measured number is the parser call itself. + +## Measurement Boundaries + +The benchmark has two separate timing boundaries: + +- `update_document` measures semantic update work after parsing has already produced the new tree. +- `parse_document` measures the Tree-sitter parser call itself. + +### Update Benchmark Boundary + +The update benchmark intentionally does not include parser time. It receives an already-parsed `new_tree`, matching the direct function boundary of `full_update_document` and `incremental_update_document`. + +Excluded from `update_document` timing: + +- generating the synthetic class text +- reading a real input file when `BENCH_INPUT_FILE` is set +- parsing the old Tree-sitter tree before setup +- parsing the new Tree-sitter tree before setup +- preparing the synthetic changed range +- constructing the initial `ProjectData` +- adding the original document before the update +- looking up the original `class_id` + +Included in `update_document` timing: + +- the call to `full_update_document` in the full-update benchmark +- the call to `incremental_update_document` in the incremental benchmark +- small benchmark harness overhead, such as `black_box` calls, cheap argument clones, and the fallback assertion + +### Parser Benchmark Boundary + +The parser benchmark is where parser time is measured. + +Excluded from `parse_document` timing: + +- generating the synthetic class text +- reading a real input file when `BENCH_INPUT_FILE` is set +- constructing the parser +- setting the parser language +- cloning the old tree +- applying the `InputEdit` to the old tree +- computing changed ranges + +Included in `parse_document` timing: + +- `Parser::parse(new_content, None)` for full parse +- `Parser::parse(new_content, Some(edited_old_tree))` for incremental parse +- small benchmark harness overhead, such as `black_box` calls and parse-result assertions + +Tree-sitter query objects are cached in static `OnceLock` values. Criterion warmup usually initializes those caches before measurement, so the reported numbers represent steady-state update cost after query compilation has already happened. + +This means the benchmark is function-focused. It is not measuring end-to-end LSP latency from text edit receipt through parsing and semantic update together. + +The presentation claim should be phrased as: + +```text +Given an already-indexed document and a parsed new tree, incremental update is X times faster than rebuilding the document index. +``` + +For parser numbers, the presentation claim should be phrased as: + +```text +For the same edit, Tree-sitter incremental parsing is X times faster than parsing the whole document from scratch. +``` + +## Incremental Fallback Guard + +The benchmark enables the `update-bench` feature. That feature adds a test-only counter around `full_update_document`. + +The incremental benchmark does this on every iteration: + +1. resets the full-update call counter +2. calls `incremental_update_document` +3. asserts that the full-update counter is still zero + +If `incremental_update_document` falls back to `full_update_document`, the benchmark fails instead of reporting a misleading incremental number. + +## Interpreting Results + +Criterion prints a timing interval like: + +```text +time: [9.5029 ms 9.5355 ms 9.5633 ms] +``` + +Criterion may print times using different units depending on how long the benchmark takes: + +| Unit | Meaning | Equivalent | +|---|---|---:| +| `s` | seconds | `1 s = 1,000 ms` | +| `ms` | milliseconds | `1 ms = 0.001 s` | +| `µs` | microseconds | `1 µs = 0.001 ms` | + +For example: + +```text +110 µs = 0.110 ms +9.54 ms = 0.00954 s +``` + +The middle value is the estimate to use for a simple comparison. For example, if the full update reports `96.03 ms` and the incremental update reports `9.54 ms`, the speedup is: + +```text +96.03 / 9.54 = 10.07x faster +``` + +For slides, use the same machine, the same benchmark settings, and the Criterion HTML report from a non-smoke run. + +For the 42 KB synthetic class used by `BENCH_METHODS=100` and `BENCH_BODY_LINES=12`, a short local run produced: + +```text +full_update_document: [95.066 ms 95.261 ms 95.467 ms] +incremental_update_document: [9.5192 ms 9.5379 ms 9.5559 ms] +full_parse_document: [8.8201 ms 8.8478 ms 8.8850 ms] +incremental_parse_document: [110.22 µs 112.99 µs 119.25 µs] +``` + +Use your own presentation run as the source of truth, but this gives the expected order of magnitude. + +For the 87 KB `test-large-dotted-statements-full.mac` fixture, a short local parser-only run produced: + +```text +full_parse_document: [23.161 ms 23.747 ms 24.182 ms] +incremental_parse_document: [1.5329 ms 1.5414 ms 1.5603 ms] +``` + +For the same 87 KB fixture, a short local update-only run produced: + +```text +full_update_document: [1.9634 s 1.9810 s 1.9981 s] +incremental_update_document: [822.05 ms 824.07 ms 826.59 ms] +``` + +The real-file update result is still guarded against fallback: if `incremental_update_document` calls `full_update_document`, the benchmark fails instead of reporting the number. diff --git a/objectscript-lsp/documentation/configuration.md b/objectscript-lsp/documentation/configuration.md new file mode 100644 index 0000000..6577870 --- /dev/null +++ b/objectscript-lsp/documentation/configuration.md @@ -0,0 +1,180 @@ +# Configuration + +ObjectScript LSP accepts configuration from the LSP client in two places: + +- `initialize.initializationOptions`, for startup configuration +- `workspace/didChangeConfiguration`, for runtime configuration updates +- `workspace/configuration`, when the client supports server-initiated configuration requests + +The canonical Rust field is `enable_strict_mode`. The canonical JSON setting is `enableStrictMode`. + +```json +{ + "enableStrictMode": true +} +``` + +The server also accepts `enable_strict_mode`, `strictMode`, `strict_mode`, and `STRICT_MODE` as compatibility aliases. + +## Settings + +| Rust field | JSON key | Default | Meaning | +|---|---|---:|---| +| `enable_snippets` | `enableSnippets` | `true` | Enable snippet-style completion items. | +| `enable_formatting` | `enableFormatting` | `true` | Advertise document formatting support during initialize. | +| `enable_lint` | `enableLint` | `true` | Enable diagnostics. When `false`, diagnostic requests return no diagnostics. | +| `enable_strict_mode` | `enableStrictMode` | `true` | Include all available diagnostics. When `false`, diagnostics are limited to syntax and XML-injected ObjectScript syntax checks. | + +`enableFormatting` is negotiated during `initialize`, so changing it at runtime does not currently re-register formatting capabilities. Diagnostic settings take effect through `workspace/didChangeConfiguration`. + +Changing an editor `settings.json` file only affects the language server if the active editor extension forwards that setting to the LSP process. The server accepts direct ObjectScript settings, an `objectscript` wrapper, flat VS Code-style keys, and Zed-style `lsp.objectscript-lsp.initialization_options`. + +## Runtime Update Behavior + +At startup, the server reads `initialize.initializationOptions` and stores the parsed config in each workspace's `ProjectData.config`. + +After startup, `workspace/didChangeConfiguration` updates the same in-memory config. If the client supports `workspace/configuration`, the server first requests current settings from the client and uses that response. If the client does not support `workspace/configuration`, the server falls back to the `settings` payload included in the `didChangeConfiguration` notification. + +Runtime updates are only applied when the payload contains ObjectScript config keys such as `enableStrictMode`, `enableLint`, or `objectscript.enableStrictMode`. Empty settings payloads and unrelated LSP settings are ignored. This prevents an editor notification with no ObjectScript settings from resetting `enableStrictMode` to its default value of `true`. + +When `enableStrictMode` changes, the server refreshes workspace diagnostics if the client supports diagnostic refresh. A semantic diagnostic such as `"Method referenced has either not yet been indexed or does not exist"` is filtered out when `enableStrictMode` is `false`. + +## Zed + +Set startup and runtime options in Zed `settings.json` under the language server id used by the extension: + +```json +{ + "lsp": { + "objectscript-lsp": { + "initialization_options": { + "enableStrictMode": false + }, + "settings": { + "enableStrictMode": false + } + } + } +} +``` + +`initialization_options` covers server startup. `settings` covers runtime `workspace/configuration` / `workspace/didChangeConfiguration` updates. + +The server also accepts a runtime-only Zed shape: + +```json +{ + "lsp": { + "objectscript-lsp": { + "settings": { + "enableStrictMode": false + } + } + } +} +``` + +If the Zed extension sends runtime settings, use the same JSON shape in the extension code and send it through `workspace/didChangeConfiguration`: + +```json +{ + "objectscript": { + "enableStrictMode": false + } +} +``` + +## Neovim + +For startup configuration, pass `init_options`: + +```lua +vim.lsp.config('objectscript_lsp', { + cmd = { '/path/to/objectscript-lsp' }, + filetypes = { 'objectscript', 'objectscript-class', 'objectscript-routine' }, + root_markers = { '.git' }, + init_options = { + enableStrictMode = false, + }, +}) + +vim.lsp.enable('objectscript_lsp') +``` + +For runtime updates, send `workspace/didChangeConfiguration` with `settings`: + +```lua +for _, client in ipairs(vim.lsp.get_clients({ name = 'objectscript_lsp' })) do + client.notify('workspace/didChangeConfiguration', { + settings = { + objectscript = { + enableStrictMode = false, + }, + }, + }) +end +``` + +## VS Code + +Expose a user-facing setting from the VS Code extension `package.json`: + +```json +{ + "contributes": { + "configuration": { + "title": "ObjectScript", + "properties": { + "objectscript.enableStrictMode": { + "type": "boolean", + "default": true, + "scope": "resource", + "description": "Enable all ObjectScript diagnostics." + } + } + } + } +} +``` + +Users set it in VS Code `settings.json`: + +```json +{ + "objectscript.enableStrictMode": false +} +``` + +The VS Code extension should pass the setting at startup: + +```ts +const objectscriptConfig = vscode.workspace.getConfiguration("objectscript"); + +const clientOptions: LanguageClientOptions = { + documentSelector: [{ scheme: "file", language: "objectscript" }], + initializationOptions: { + enableStrictMode: objectscriptConfig.get("enableStrictMode", true), + }, +}; +``` + +To apply changes without restarting the language server, listen for VS Code configuration changes and send `workspace/didChangeConfiguration`: + +```ts +context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (!event.affectsConfiguration("objectscript")) { + return; + } + + const objectscriptConfig = vscode.workspace.getConfiguration("objectscript"); + client.sendNotification("workspace/didChangeConfiguration", { + settings: { + objectscript: { + enableStrictMode: objectscriptConfig.get("enableStrictMode", true), + }, + }, + }); + }), +); +``` diff --git a/objectscript-lsp/documentation/features/diagnostics.md b/objectscript-lsp/documentation/features/diagnostics.md index aff4c5e..9a303c8 100644 --- a/objectscript-lsp/documentation/features/diagnostics.md +++ b/objectscript-lsp/documentation/features/diagnostics.md @@ -43,9 +43,18 @@ For XML files that contain ObjectScript code inside `` CDATA blo This provides syntax checking for ObjectScript code that is embedded within XML class export files. +### Project Semantic Diagnostics + +Project semantic diagnostics are produced from workspace indexes and cross-document semantic state. These include unresolved method-reference diagnostics such as `"Method referenced has either not yet been indexed or does not exist"`. + +These diagnostics are included only when `enableStrictMode` is `true`. + ## Behavior - Diagnostics are computed on-demand via the `textDocument/diagnostic` pull model (not push-based `textDocument/publishDiagnostics`) - Each diagnostic request returns a full report for the requested document -- Inter-file dependencies are not tracked (a change in one file does not trigger diagnostics in another) -- Workspace-level diagnostics are not supported (only per-document) +- Workspace-level diagnostics are returned via `workspace/diagnostic` for tracked documents +- `enableLint: false` disables diagnostics +- `enableStrictMode: false` keeps syntax diagnostics enabled but filters out project semantic diagnostics, including unresolved method-reference diagnostics +- Runtime changes sent through `workspace/didChangeConfiguration` update diagnostic behavior without restarting the server +- Empty configuration notifications and unrelated LSP settings are ignored so they do not reset strict mode to its default diff --git a/objectscript-lsp/documentation/features/goto-def.md b/objectscript-lsp/documentation/features/goto-def.md index 62ed409..7602a58 100644 --- a/objectscript-lsp/documentation/features/goto-def.md +++ b/objectscript-lsp/documentation/features/goto-def.md @@ -78,7 +78,9 @@ Scopes that create boundaries for variable resolution: ## Inheritance Rules for Superclass Navigation -When navigating from a method definition to its superclass override, the LSP uses the OverrideIndex which respects ObjectScript's inheritance rules: if a class inherits from multiple superclasses that define the same method, the override resolution follows the language's defined precedence order. +When navigating from a method definition to its superclass override, or from a relative method call to an inherited method, the LSP uses the OverrideIndex. For multiple inheritance, the default precedence is left-to-right through the `Extends (...)` list. If the class declares `[Inheritance = right]`, the precedence is right-to-left. + +Inherited members count when applying that precedence. For example, if `Demo.ChildDefault Extends (Demo.LeftParent, Demo.RightParent)`, `Demo.LeftParent Extends Demo.Base`, `Demo.Base` defines `Common`, and `Demo.RightParent` also defines `Common`, goto-definition from `Demo.ChildDefault` should resolve `Common` to `Demo.Base`. `Demo.LeftParent` comes first, so its effective inherited `Common` wins over `Demo.RightParent.Common`. ## TODO I still need to use `kill` and `new` statements in my analysis of what variable definitions are actually valid from a given method. If a `kill` statement appears, any definitions that came before that should be nullified. diff --git a/objectscript-lsp/documentation/features/goto-implementation.md b/objectscript-lsp/documentation/features/goto-implementation.md index ad53651..e0fd906 100644 --- a/objectscript-lsp/documentation/features/goto-implementation.md +++ b/objectscript-lsp/documentation/features/goto-implementation.md @@ -2,7 +2,7 @@ ## Overview -The goto-implementation feature navigates from a class or method definition to its **subclass overrides**. This is the inverse of goto-definition's superclass navigation — where goto-def goes "up" the inheritance chain, goto-implementation goes "down" to find subclasses and overriding methods. +The goto-implementation feature navigates from a class or method definition to its **subclass method_overrides**. This is the inverse of goto-definition's superclass navigation — where goto-def goes "up" the inheritance chain, goto-implementation goes "down" to find subclasses and overriding methods. ## Supported Symbols diff --git a/objectscript-lsp/documentation/features/implementations/diagnostics-implementation.md b/objectscript-lsp/documentation/features/implementations/diagnostics-implementation.md index 45fd51a..a90936e 100644 --- a/objectscript-lsp/documentation/features/implementations/diagnostics-implementation.md +++ b/objectscript-lsp/documentation/features/implementations/diagnostics-implementation.md @@ -2,7 +2,7 @@ ## Entry Point -`lsp.rs:588` — `async fn diagnostic` +`src/lsp.rs` — `async fn diagnostic` ## Architecture @@ -10,9 +10,11 @@ ┌─────────────────────────────────────────────────────────────┐ │ LSP Handler (lsp.rs:diagnostic) │ │ - Gets document snapshot (file_type, content, tree) │ +│ - Checks ProjectData.config diagnostic gates │ │ - Calls push_host_syntax_diagnostics for all file types │ │ - For XML: additionally calls │ │ push_xml_injected_objectscript_diagnostics │ +│ - In strict mode: adds project semantic diagnostics │ │ - Returns FullDocumentDiagnosticReport │ └────────────────────────────┬────────────────────────────────┘ │ @@ -33,36 +35,52 @@ ## Server Capabilities -Registered in `build_caps` (`lsp.rs:296`): +Registered in `build_caps` in `src/lsp.rs`: ```rust diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions { identifier: None, - inter_file_dependencies: false, - workspace_diagnostics: false, + inter_file_dependencies: true, + workspace_diagnostics: true, work_done_progress_options: Default::default(), })) ``` This registers pull-based diagnostics (client requests via `textDocument/diagnostic`), not push-based. -## Handler Flow (`lsp.rs:588`) +## Handler Flow (`src/lsp.rs`) ``` 1. Get document URI from params 2. Get project from document URL -3. Take snapshot: (file_type, content, tree) from project state -4. Call push_host_syntax_diagnostics(diagnostics, content, tree, file_type) -5. If file_type == XML: - a. Record host error count - b. Call push_xml_injected_objectscript_diagnostics(diagnostics, content, tree) - c. Log host vs total error counts -6. Return FullDocumentDiagnosticReport with all diagnostics +3. Read the workspace `ProjectData` +4. Return no diagnostics if `data.config.enable_lint` is false +5. Call push_host_syntax_diagnostics(diagnostics, content, tree, file_type) +6. If file_type == XML, call push_xml_injected_objectscript_diagnostics(diagnostics, content, tree) +7. If `data.config.enable_strict_mode` is true, add project semantic diagnostics +8. Return FullDocumentDiagnosticReport with the selected diagnostics ``` +## Configuration Flow + +Startup config is parsed from `initialize.initializationOptions` with `Config::from_lsp_value` and stored in each workspace's `ProjectData.config`. + +Runtime config changes are handled by `did_change_configuration`: + +``` +1. If the client supports workspace/configuration, request current settings +2. Parse the first response that contains ObjectScript config keys with Config::from_lsp_value_if_present +3. If workspace/configuration is unavailable, parse the notification's settings payload +4. Ignore empty payloads and unrelated LSP settings +5. Apply the parsed config to each workspace ProjectData +6. Refresh workspace diagnostics when the effective config changed +``` + +This guard matters because many clients send `workspace/didChangeConfiguration` with an empty payload, or return broad LSP settings from `workspace/configuration`. Those payloads must not be treated as `Config::default()`, because that would reset `enable_strict_mode` to `true` and re-enable semantic diagnostics. + ## Key Functions -### `push_host_syntax_diagnostics` (`lsp.rs:131`) +### `push_host_syntax_diagnostics` (`src/lsp.rs`) Finds all error nodes in the document's tree-sitter parse tree and converts them to LSP diagnostics. @@ -78,7 +96,7 @@ Finds all error nodes in the document's tree-sitter parse tree and converts them d. Push Diagnostic with severity ERROR ``` -### `push_xml_injected_objectscript_diagnostics` (`lsp.rs:164`) +### `push_xml_injected_objectscript_diagnostics` (`src/lsp.rs`) Finds ObjectScript code embedded in XML `` blocks and runs syntax checking on each. diff --git a/objectscript-lsp/documentation/features/implementations/goto-def-implementation.md b/objectscript-lsp/documentation/features/implementations/goto-def-implementation.md index c017af2..5ee65ef 100644 --- a/objectscript-lsp/documentation/features/implementations/goto-def-implementation.md +++ b/objectscript-lsp/documentation/features/implementations/goto-def-implementation.md @@ -65,7 +65,7 @@ Additionally, bare `routine_name` nodes and `numeric_literal` nodes (for line of ### `get_method_superclass` (`workspace.rs:1503`) - Finds the `MethodRef` for the method in the current class -- Looks up `override_index.overrides` to find the superclass `MethodRef` it overrides +- Looks up `override_index.method_overrides` to find the superclass `MethodRef` it overrides - Returns the location from the superclass method symbol ### `get_class_definition` (`workspace.rs:1439`) @@ -120,17 +120,52 @@ A directed graph (`petgraph::DiGraph`) where: ### OverrideIndex (`override_index.rs`) Tracks method overriding relationships: -- `overrides`: subclass `MethodRef` → superclass `MethodRef` it overrides -- `overridden_by`: superclass `MethodRef` → all subclass `MethodRef`s that override it -- `effective_public_methods`: per-class map of method name → resolved `MethodRef` +- `method_overrides`: subclass `MethodRef` → superclass `MethodRef` it overrides +- `method_overridden_by`: superclass `MethodRef` → all subclass `MethodRef`s that override it +- `effective_methods`: per-class map of method name → resolved `MethodRef` Used by `get_method_superclass` for navigating up the inheritance chain. +#### Multiple Inheritance Precedence + +`build_override_index_for_classes` builds each class's effective member tables from its parents before overlaying the class's own members. Parent order is significant: +- Default inheritance processes `class.inherited_classes` from left to right. +- `[Inheritance = right]` processes the same list in reverse order. +- Parent members are inserted with first-wins semantics, so the first parent that exposes a method/property/parameter name owns that effective entry. + +This means inherited parent members are treated the same as members declared directly on a parent. In the multiple-inheritance regression fixture, `Demo.ChildDefault Extends (Demo.LeftParent, Demo.RightParent)`, `Demo.LeftParent Extends Demo.Base`, `Demo.Base` defines `Common`, and `Demo.RightParent` also defines `Common`. Because default inheritance is left-to-right, `Demo.ChildDefault.Common` must resolve to `Demo.Base.Common`, not `Demo.RightParent.Common`. + +#### Late Indexing and Stale Effective Tables + +Workspace indexing and `didOpen` events can arrive in an order where a child is indexed before one of its parents. The failing scenario was: +- `Demo.Base` and `Demo.RightParent` were known. +- `Demo.ChildDefault` was indexed while `Demo.LeftParent` was still unresolved, so `Common` could temporarily resolve through `Demo.RightParent`. +- `Demo.LeftParent` was indexed later and inherited `Common` from `Demo.Base`. +- The child effective table was not always rebuilt after that parent became resolvable, so goto-definition could keep returning `Demo.RightParent.Common`. + +The fix keeps the override index and the goto-definition lookup maps synchronized when inheritance resolution changes: +- `new_class_inheritance` adds classes collected while resolving unresolved parent references to the full override rebuild set before rebuilding. +- `update_class_inheritance` does the same for inheritance edits by rebuilding the current class, direct dependents, and any additional classes gathered while reconnecting inheritance edges. +- `rebuild_override_index_for_classes_and_apply` wraps `build_override_index_for_classes` and merges returned inherited methods, properties, and parameters into `method_defs`, `property_defs`, and `parameter_defs`. + +The final step matters because relative method goto-definition consults `method_defs[class_name][method_name]` before falling back to `override_index.effective_methods`. If the override index is correct but `method_defs` is stale or missing an inherited method, goto-definition can still navigate to the wrong place or fail to navigate. + +The regression tests cover both sides of the rule: +- `test_goto_def_multiple_inheritance_default_left` verifies `Demo.ChildDefault.Common` resolves through `Demo.LeftParent` to `Demo.Base`. +- `test_goto_def_multiple_inheritance_late_left_parent_prefers_base` indexes `Base`, `RightParent`, `ChildDefault`, then `LeftParent` to reproduce the stale-state ordering and verifies both `override_index.effective_methods` and `method_defs` point at `Demo.Base.Common`. +- `test_goto_def_multiple_inheritance_right_direction` verifies `[Inheritance = right]` still resolves `Common` through `Demo.RightParent`. + +Relevant code paths: +- `ProjectData::new_class_inheritance` and `ProjectData::update_class_inheritance` decide which classes must be rebuilt after inheritance resolution changes. +- `ProjectData::build_override_index_for_classes` computes effective inherited members in parent-precedence order. +- `ProjectData::rebuild_override_index_for_classes_and_apply` keeps the override index and goto-definition lookup maps aligned. +- The regression tests live in `src/test.rs` near the multiple-inheritance goto-definition tests. + ### GlobalSemanticModel Stores workspace-wide symbols: - Class symbols (`ClassGlobalSymbol`) — name, url, location -- Method symbols (`MethodGlobalSymbol`) — for public methods +- Method symbols (`MethodSymbol`) — for public methods - Variable symbols (`VariableGlobalSymbol`) — for public variables ### ProjectState Fields diff --git a/objectscript-lsp/documentation/features/implementations/goto-implementation-impl.md b/objectscript-lsp/documentation/features/implementations/goto-implementation-impl.md index 0273150..a73eafc 100644 --- a/objectscript-lsp/documentation/features/implementations/goto-implementation-impl.md +++ b/objectscript-lsp/documentation/features/implementations/goto-implementation-impl.md @@ -109,7 +109,7 @@ Input: class_id ``` Input: method_ref (the superclass method) -1. Look up override_index.overridden_by[method_ref] → Vec +1. Look up override_index.method_overridden_by[method_ref] → Vec 2. For each overriding MethodRef: a. Try GlobalSemanticModel.get_method_symbol → (url, range) [public methods] b. Fallback: get document by class url → scope_tree.get_private_method_symbol → (url, range) [private methods] @@ -130,10 +130,10 @@ Maps each class to all classes that directly inherit from it. Built during works ### OverrideIndex (`override_index.rs`) -The `overridden_by` field is the key structure for this feature: +The `method_overridden_by` field is the key structure for this feature: ```rust -pub overridden_by: HashMap> +pub method_overridden_by: HashMap> ``` Maps a superclass method → all subclass methods that override it. This is the inverse of the `overrides` map used by goto-definition. diff --git a/objectscript-lsp/documentation/features/implementations/refactor-implementation.md b/objectscript-lsp/documentation/features/implementations/refactor-implementation.md index 5149d27..9ecca5c 100644 --- a/objectscript-lsp/documentation/features/implementations/refactor-implementation.md +++ b/objectscript-lsp/documentation/features/implementations/refactor-implementation.md @@ -13,12 +13,12 @@ │ Code Action Provider (lsp.rs:code_action) │ │ - Checks if refactor.rewrite kind is requested │ │ - Builds menu of available refactor commands per file type │ -│ - Returns CodeAction list with command references │ +│ - Returns CodeAction list with command references │ └────────────────────────────┬────────────────────────────────┘ │ user selects action ▼ ┌─────────────────────────────────────────────────────────────┐ -│ Execute Command (lsp.rs:execute_command) │ +│ Execute Command (lsp.rs:execute_command) │ │ - Parses URI + RefactorLevel from command arguments │ │ - Dispatches to document or workspace refactor │ │ - Sends workspace/applyEdit to editor │ @@ -29,8 +29,8 @@ ▼ ▼ ▼ ┌──────────────┐ ┌─────────────┐ ┌─────────────┐ │ refactor_ │ │ refactor_ │ │ refactor_ │ - │ legacy_do_ │ │ conditionals│ │ for_ │ - │ statements()│ │ () │ │ statements()│ + │ legacy_do_ │ │ conditionals │ │ for_ │ + │ statements()│ │ () │ │ statements()│ └──────────────┘ └─────────────┘ └─────────────┘ ``` @@ -240,7 +240,7 @@ Uses tree-sitter queries to find legacy statements with no body (no expression + | Function | Purpose | |---|---| | `remove_unreachable_for_statements` | First pass: removes dead for statements | -| `refactor_legacy_for_statements` | Loop converting old for → block form | +| `refactor_legacy_for_statement_to_block` | Loop converting old for → block form | | `refactor_old_for_statements` | Single transformation of one for statement | --- diff --git a/objectscript-lsp/objectscript-tests/diagnostics/clean.mac b/objectscript-lsp/objectscript-tests/diagnostics/clean.mac index 7cc5b66..65990e4 100644 --- a/objectscript-lsp/objectscript-tests/diagnostics/clean.mac +++ b/objectscript-lsp/objectscript-tests/diagnostics/clean.mac @@ -4,3 +4,12 @@ main set x = 1 w x quit + +dottedComment + do + . /*w hi*/ + . set x = 1 + . if x { + . w "bye" if z d + . w "in z" + . } diff --git a/objectscript-lsp/objectscript-tests/dotted-block/test-dotted-block.mac b/objectscript-lsp/objectscript-tests/dotted-block/test-dotted-block.mac index f0b5cc9..1369fc1 100644 --- a/objectscript-lsp/objectscript-tests/dotted-block/test-dotted-block.mac +++ b/objectscript-lsp/objectscript-tests/dotted-block/test-dotted-block.mac @@ -1,16 +1,16 @@ ROUTINE test dotted - if 1 d - . if 1 { + if 1 d + . if 1 { . w "hi" . } #; comment dottedComment - do - . /*w hi*/ + do + . /*w hi*/ . set x = 1 . if x { . w "bye" diff --git a/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/base.cls b/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/base.cls index d98f563..a8e1d8c 100644 --- a/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/base.cls +++ b/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/base.cls @@ -5,7 +5,7 @@ ClassMethod Shared() Write "base" } -Method Common() +ClassMethod Common() { Write "base common" } diff --git a/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/child-default.cls b/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/child-left.cls similarity index 73% rename from objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/child-default.cls rename to objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/child-left.cls index 81db60c..604f5ac 100644 --- a/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/child-default.cls +++ b/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/child-left.cls @@ -1,7 +1,8 @@ Class Demo.ChildDefault Extends (Demo.LeftParent, Demo.RightParent) { -Method UseParent() +ClassMethod UseParent() { do ##class(Demo.LeftParent).Shared() + d ..Common() } } diff --git a/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/child-right.cls b/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/child-right.cls index e792db5..aa0aee9 100644 --- a/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/child-right.cls +++ b/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/child-right.cls @@ -1,7 +1,8 @@ Class Demo.ChildRight Extends (Demo.LeftParent, Demo.RightParent) [Inheritance = right] { -Method UseParent() +ClassMethod UseParent() { do ##class(Demo.RightParent).Shared() + d ..Common() } } diff --git a/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/left-parent.cls b/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/left-parent.cls index b91fcc9..f1d2c54 100644 --- a/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/left-parent.cls +++ b/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/left-parent.cls @@ -4,9 +4,4 @@ ClassMethod Shared() { Write "left" } - -Method Common() -{ - Write "left common" -} } diff --git a/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/right-parent.cls b/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/right-parent.cls index 0849ef5..da5ec3c 100644 --- a/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/right-parent.cls +++ b/objectscript-lsp/objectscript-tests/gotodef/multiple-inheritance/right-parent.cls @@ -5,7 +5,7 @@ ClassMethod Shared() Write "right" } -Method Common() +ClassMethod Common() { Write "right common" } diff --git a/objectscript-lsp/objectscript-tests/gotodef/relative-method-call/hk.cls b/objectscript-lsp/objectscript-tests/gotodef/relative-method-call/hk.cls new file mode 100644 index 0000000..88c853c --- /dev/null +++ b/objectscript-lsp/objectscript-tests/gotodef/relative-method-call/hk.cls @@ -0,0 +1,5 @@ +Class hk { + ClassMethod print2() [Private] { + w 2 + } +} diff --git a/objectscript-lsp/objectscript-tests/gotodef/relative-method-call/hksubclass.cls b/objectscript-lsp/objectscript-tests/gotodef/relative-method-call/hksubclass.cls new file mode 100644 index 0000000..009c8e4 --- /dev/null +++ b/objectscript-lsp/objectscript-tests/gotodef/relative-method-call/hksubclass.cls @@ -0,0 +1,6 @@ +Class hksubclass Extends (hk, %RegisteredObject) { + Property x; + ClassMethod callInheritedMethod() { + d ..print2() + } +} diff --git a/objectscript-lsp/objectscript-tests/gotodef/routines/cross-routine-ref.mac b/objectscript-lsp/objectscript-tests/gotodef/routines/cross-routine-ref.mac index bd46364..efeb62d 100644 --- a/objectscript-lsp/objectscript-tests/gotodef/routines/cross-routine-ref.mac +++ b/objectscript-lsp/objectscript-tests/gotodef/routines/cross-routine-ref.mac @@ -1,5 +1,8 @@ ROUTINE crossref - entry + set x = 1 do helper^tagcalls quit + +testOne +testTwo diff --git a/objectscript-lsp/objectscript-tests/gotodef/routines/offset-goto.mac b/objectscript-lsp/objectscript-tests/gotodef/routines/offset-goto.mac index 64fc372..03d15aa 100644 --- a/objectscript-lsp/objectscript-tests/gotodef/routines/offset-goto.mac +++ b/objectscript-lsp/objectscript-tests/gotodef/routines/offset-goto.mac @@ -2,7 +2,7 @@ ROUTINE offsetgoto main set x = 1 - do main+2 + do main+3 w !, "line after main" w !, "target of main+2" quit diff --git a/objectscript-lsp/objectscript-tests/gotodef/routines/tag-calls.mac b/objectscript-lsp/objectscript-tests/gotodef/routines/tag-calls.mac index 24130c7..c1c17fd 100644 --- a/objectscript-lsp/objectscript-tests/gotodef/routines/tag-calls.mac +++ b/objectscript-lsp/objectscript-tests/gotodef/routines/tag-calls.mac @@ -8,6 +8,7 @@ main quit helper + w !, x w !, "helping" quit diff --git a/objectscript-lsp/objectscript-tests/routines/test-testing.mac b/objectscript-lsp/objectscript-tests/routines/test-testing.mac index 15af3f0..13e816e 100644 --- a/objectscript-lsp/objectscript-tests/routines/test-testing.mac +++ b/objectscript-lsp/objectscript-tests/routines/test-testing.mac @@ -3,7 +3,7 @@ ROUTINE x test do testSubroutine1 q -#; +#; testSubroutine1 set x = "hi" diff --git a/objectscript-lsp/objectscript-tests/test-suite.md b/objectscript-lsp/objectscript-tests/test-suite.md deleted file mode 100644 index d949b73..0000000 --- a/objectscript-lsp/objectscript-tests/test-suite.md +++ /dev/null @@ -1,264 +0,0 @@ -# ObjectScript LSP Test Suite - -## 1. Goto Definition - -### 1.1 Classes — Method Definitions (Superclass Resolution) - -| # | Scenario | Expected | -|---|----------|----------| -| 1.1.1 | Click method name in a `class_definition` that overrides a superclass method | Jump to superclass method definition | -| 1.1.2 | Click method name that is NOT defined in any superclass | Return None | -| 1.1.3 | Multiple inheritance (default left): click method defined in both parents | Jump to left parent's definition | -| 1.1.4 | Multiple inheritance `[Inheritance = right]`: click method defined in both parents | Jump to right parent's definition | -| 1.1.5 | Method with `objectscript_identifier_special` (e.g., `%New`) | Resolves correctly | -| 1.1.6 | Subclass opened before superclass is indexed | Still resolves after superclass is loaded | - -### 1.2 Classes — Class References - -| # | Scenario | Expected | -|---|----------|----------| -| 1.2.1 | Click class name in `Extends` clause | Jump to that class's `class_definition` node | -| 1.2.2 | Click class name in `##class(ClassName)` | Jump to class definition | -| 1.2.3 | Click class name that doesn't exist in workspace | Return None | -| 1.2.4 | Routine name in `do label^routine` | Jump to routine definition | - -### 1.3 Classes — Class Method Calls - -| # | Scenario | Expected | -|---|----------|----------| -| 1.3.1 | `##class(Foo).Bar()` — click on `Foo` | Jump to class Foo definition | -| 1.3.2 | `##class(Foo).Bar()` — click on `Bar` | Jump to method `Bar` in class `Foo` | -| 1.3.3 | Method doesn't exist in target class | Return None | - -### 1.4 Classes — Oref (Object Reference) Methods - -| # | Scenario | Expected | -|---|----------|----------| -| 1.4.1 | `set x = ##class(Foo).%New()` then `d x.Method()` — click `x` | Jump to definition of `x` | -| 1.4.2 | Same — click `Method` | Jump to `Method` definition in class `Foo` | -| 1.4.3 | Oref in `do_parameter` context: `do obj.Run()` | Same resolution as above | -| 1.4.4 | Oref in `job_argument` context: `job obj.Execute()` | Same resolution as above | -| 1.4.5 | Multi-segment oref chains (>2 children) | Currently unsupported, returns None | - -### 1.5 Classes — Relative Method Calls - -| # | Scenario | Expected | -|---|----------|----------| -| 1.5.1 | `d ..MethodName()` — click on `MethodName` | Jump to method def in current class | -| 1.5.2 | Relative call to method that doesn't exist in current class | Return None | - -### 1.6 Variables - -| # | Scenario | Expected | -|---|----------|----------| -| 1.6.1 | Private variable in `[ProcedureBlock=1]` method — click on usage | Jump to `set` definition in same method | -| 1.6.2 | Public variable (class is `[Not ProcedureBlock]`) — click on usage in method where defined | Jump to local definition | -| 1.6.3 | Public variable — click on usage in method where NOT defined | Return all reachable definitions from dependency graph | -| 1.6.4 | Public variable — unreachable method's definition is NOT returned | Verify via dependency graph path analysis | -| 1.6.5 | Variable in method header (`Pub` argument) | Look in method first, then workspace | -| 1.6.6 | Global variable (`^gvn`) — click on identifier inside gvn node | Jump to definition if in scope | -| 1.6.7 | Variable defined in multiple reachable methods | Return all reachable definitions | - -### 1.7 Routines — Goto Definition - -| # | Scenario | Expected | -|---|----------|----------| -| 1.7.1 | `do label` (tag call within same routine) | Jump to label definition | -| 1.7.2 | `do label^routine` (cross-routine tag call) | Jump to label in other routine | -| 1.7.3 | `goto label` | Jump to label definition | -| 1.7.4 | Numeric offset: `do label+N` — click on N | Jump to N lines below current position | -| 1.7.5 | Variable in subroutine (public by default) | Resolve across visible scope | -| 1.7.6 | Variable in procedure (private by default) | Resolve only within procedure | -| 1.7.7 | `write` argument / `print_argument` with tag reference | Jump to tag | - -### 1.8 Edge Cases - -| # | Scenario | Expected | -|---|----------|----------| -| 1.8.1 | Click on a keyword (not identifier) | Return None | -| 1.8.2 | Click on whitespace | Return None (no named descendant) | -| 1.8.3 | Click on undefined symbol | Return None gracefully (no crash) | -| 1.8.4 | Document has parse errors — click on valid node | Still resolves definitions | -| 1.8.5 | Empty file | Return None | -| 1.8.6 | File with only a routine header and no body | Return None | - ---- - -## 2. Goto Implementation - -### 2.1 Method Overrides - -| # | Scenario | Expected | -|---|----------|----------| -| 2.1.1 | Click method def that is overridden in 2 subclasses | Array with both subclass locations | -| 2.1.2 | Click method def with no overrides | None + warning message | -| 2.1.3 | Click method overridden in 3+ subclasses (deep hierarchy) | Array with all override locations | -| 2.1.4 | Private method override is included | Override appears in results regardless of visibility | -| 2.1.5 | Click method in class method call (`##class(X).Y()`) | Find overrides of Y across subclasses of X | - -### 2.2 Class Implementations - -| # | Scenario | Expected | -|---|----------|----------| -| 2.2.1 | Click class name in `class_definition` node | All direct subclasses | -| 2.2.2 | Click class name that is referenced (not in `class_definition`) | Subclasses of that referenced class | -| 2.2.3 | Class with no subclasses | None | - -### 2.3 Edge Cases - -| # | Scenario | Expected | -|---|----------|----------| -| 2.3.1 | Click on non-identifier node | Return None | -| 2.3.2 | Class not found in workspace | Return None (no crash) | - ---- - -## 3. Document Diagnostics - -### 3.1 Syntax Error Detection - -| # | Scenario | Expected | -|---|----------|----------| -| 3.1.1 | Clean `.cls` file with no errors | Zero diagnostics | -| 3.1.2 | `.cls` with syntax error (e.g., `set =`) | One or more ERROR diagnostics | -| 3.1.3 | Clean `.mac` routine | Zero diagnostics | -| 3.1.4 | `.mac` with multiple syntax errors | Multiple diagnostics returned | -| 3.1.5 | Error message includes the unexpected token text | Message contains the error text | - -### 3.2 XML Diagnostics - -| # | Scenario | Expected | -|---|----------|----------| -| 3.2.1 | XML with valid ObjectScript in CDATA `` block | Zero injected diagnostics | -| 3.2.2 | XML with syntax errors in CDATA `` block | Injected ObjectScript diagnostics returned | -| 3.2.3 | XML host-level syntax error (malformed XML) | XML syntax error diagnostic | -| 3.2.4 | XML with empty `` block | Zero diagnostics for that block | -| 3.2.5 | XML with fake/malformed CDATA markers | Still detects injection ranges and reports errors | - -### 3.3 Diagnostic Consistency - -| # | Scenario | Expected | -|---|----------|----------| -| 3.3.1 | Request diagnostics for document not in any project | Return empty report (not crash) | -| 3.3.2 | Request diagnostics for document not yet tracked | Return empty report | - ---- - -## 4. Code Actions (Refactoring) - -### 4.1 Document Refactoring - -| # | Scenario | Expected | -|---|----------|----------| -| 4.1.1 | `.mac` file — all 4 refactor levels offered (Do, Conditionals, For, All) | Actions list contains all 4 | -| 4.1.2 | `.cls` file — only 3 refactor levels (Conditionals, For, All) | No "Do" action for class files | -| 4.1.3 | `.xml` file — no document refactor levels | Empty actions or None | -| 4.1.4 | Document has parse errors | No document-level refactor actions offered | -| 4.1.5 | Refactor produces no changes (already modern syntax) | `build_document_refactor_edit` returns None | - -### 4.2 Workspace Refactoring - -| # | Scenario | Expected | -|---|----------|----------| -| 4.2.1 | Workspace refactor finds changes across multiple files | HashMap with multiple URLs | -| 4.2.2 | Workspace refactor with no legacy syntax anywhere | Empty changes | -| 4.2.3 | Trigger kind is AUTOMATIC | No workspace refactors in response | -| 4.2.4 | XML files are excluded from workspace refactor | Only `.cls`/`.mac` files in changes | - -### 4.3 Execute Command - -| # | Scenario | Expected | -|---|----------|----------| -| 4.3.1 | `objectscript.refactorDocument` with valid args | Edit applied | -| 4.3.2 | `objectscript.refactorWorkspace` with valid args | Edit applied | -| 4.3.3 | Legacy command `objectscript.refactorWorkspaceDottedDo` | Treated as DoCommands level | -| 4.3.4 | Missing URI argument | Logs error, returns None | -| 4.3.5 | Missing/invalid refactor level argument | Logs error, returns None | - ---- - -## 5. Document Sync (didOpen / didChange) - -### 5.1 didOpen - -| # | Scenario | Expected | -|---|----------|----------| -| 5.1.1 | Open `.cls` file | Document tracked with correct FileType, class_id, class_name populated | -| 5.1.2 | Open `.mac` file | Document tracked as Routine, class_name = routine name | -| 5.1.3 | Open `.xml` file | Document tracked with FileType::Xml, no class_id/class_name | -| 5.1.4 | Open unsupported extension (`.txt`) | Ignored (no tracking) | - -### 5.2 didChange — Incremental - -| # | Scenario | Expected | -|---|----------|----------| -| 5.2.1 | Single ranged edit in middle of file | Content updated, tree re-parsed incrementally | -| 5.2.2 | Multiple sequential ranged edits in one notification | All edits applied in order | -| 5.2.3 | Full-text replacement (range = None) | Old tree discarded, full reparse | -| 5.2.4 | After edit, diagnostics reflect new content | New errors appear / old errors gone | -| 5.2.5 | After edit, goto_definition reflects new content | Definitions from updated code | -| 5.2.6 | Version is older than current | Warning logged (but still processed) | - -### 5.3 didChangeWatchedFiles - -| # | Scenario | Expected | -|---|----------|----------| -| 5.3.1 | New file created on disk | File gets indexed (handle_document_opened) | -| 5.3.2 | Existing file modified on disk | Re-indexed with new content | -| 5.3.3 | File deleted | Skipped (no action) | -| 5.3.4 | Non-ObjectScript file changes | Skipped | - ---- - -## 6. Ordering & Timing - -### 6.1 Index Order Independence - -| # | Scenario | Expected | -|---|----------|----------| -| 6.1.1 | Subclass opened/indexed BEFORE superclass | After superclass loads, goto_def resolves to superclass | -| 6.1.2 | Superclass opened first, then subclass | Normal resolution works | -| 6.1.3 | File re-opened after disk modification (via watcher) | State reflects latest content | -| 6.1.4 | Same class opened twice (duplicate) | Second open is idempotent or updates version | - -### 6.2 Cross-File Consistency - -| # | Scenario | Expected | -|---|----------|----------| -| 6.2.1 | Class referenced in method call isn't in workspace | Returns None (not crash) | -| 6.2.2 | Inheritance chain partially loaded | Available links resolve; missing links return None | -| 6.2.3 | update_document triggers rebuild of inheritance/variables | Overrides and variables are current | - ---- - -## 7. Capability Negotiation - -| # | Scenario | Expected | -|---|----------|----------| -| 7.1 | Config with `enable_formatting: true` | `document_formatting_provider` = Some | -| 7.2 | Config with `enable_formatting: false` (default) | `document_formatting_provider` = None | -| 7.3 | Server always advertises definition, implementation, diagnostics | Capabilities present | - ---- - -## Test Fixture Directories - -``` -objectscript-tests/ -├── diagnostics/ — syntax error detection tests -├── gotodef/ -│ ├── class-method-call/ — ##class(X).Y() resolution -│ ├── multiple-inheritance/ — left/right inheritance -│ ├── oref-contexts/ — oref in do_parameter, job_argument -│ └── routines/ — tag calls, offsets, cross-routine refs -├── implementation/ — override resolution with deep hierarchy -├── inheritance/ — class keyword inheritance -├── navigation/implementation/ — public/private overrides -├── ordering/ — index order independence tests -├── variables/ — public/private variable scoping -├── dependencies/ — method call dependency tracking -├── routines/ — refactoring tests -├── nested_dots/ — nested dotted statement tests -├── dotted-block/ — dotted block refactoring -└── local/ — large dotted statement tests -``` diff --git a/objectscript-lsp/routines/cross-routine-ref.mac b/objectscript-lsp/routines/cross-routine-ref.mac new file mode 100644 index 0000000..807c7b4 --- /dev/null +++ b/objectscript-lsp/routines/cross-routine-ref.mac @@ -0,0 +1,12 @@ +ROUTINE crossref +entry + w "entering" + do helper^tagcalls + quit + +testOne + set x = 25 + q +testTwo + set x = 26 + q diff --git a/objectscript-lsp/routines/offset-goto.mac b/objectscript-lsp/routines/offset-goto.mac new file mode 100644 index 0000000..6c280d5 --- /dev/null +++ b/objectscript-lsp/routines/offset-goto.mac @@ -0,0 +1,9 @@ +ROUTINE offsetgoto + +main + set x = 72 + d entry^crossref + do main+3 + w !, "line after main" + w !, "target of main+2" + quit diff --git a/objectscript-lsp/routines/tag-calls.mac b/objectscript-lsp/routines/tag-calls.mac new file mode 100644 index 0000000..a4d5704 --- /dev/null +++ b/objectscript-lsp/routines/tag-calls.mac @@ -0,0 +1,17 @@ +ROUTINE tagcalls + +main + set x = 1 + do helper + do helper^tagcalls + goto finish + quit + +helper + w !, "Iteration", x + w !, "helping" + quit + +finish + w !, "done" + quit diff --git a/objectscript-lsp/src/backend_testing.rs b/objectscript-lsp/src/backend_testing.rs index 97cb1b5..e8fd820 100644 --- a/objectscript-lsp/src/backend_testing.rs +++ b/objectscript-lsp/src/backend_testing.rs @@ -1,11 +1,11 @@ -use objectscript_core::common::get_member_name_from_root; +use objectscript_core::common::{get_member_name_and_range_from_root, ts_range_to_lsp_range}; 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 tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Url}; use tree_sitter::Parser; use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; use tree_sitter_objectscript_routine::LANGUAGE_OBJECTSCRIPT_ROUTINE; @@ -140,29 +140,45 @@ impl BackendTester { false }; - let member_name = - get_member_name_from_root(code.as_str(), tree.root_node(), is_rtn); - - // Commit inside the ProjectData lock + if let Some((member_range, member_name, class_name_def_range)) = + get_member_name_and_range_from_root(code.as_str(), tree.root_node(), is_rtn) { let mut data = project.data.write(); + let workspace_contains_class_name = data.classes.contains_key(&member_name); let already_exists = data.add_document_if_absent( url.clone(), - code, - tree, + code.clone(), + &tree, filetype, - member_name, + member_name.clone(), + member_range, None, ); if already_exists { documents_already_existing.push(url); + } else if !already_exists && workspace_contains_class_name { + let lsp_range = ts_range_to_lsp_range(code.as_str(), class_name_def_range); + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::ERROR), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "A Class named {:?} already exists in this workspace.", + &member_name + ), + related_information: None, + tags: None, + data: None, + }; + data.other_class_diagnostics + .entry(url.clone()) + .or_insert(Vec::new()) + .push(diagnostic); } } } - { - 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 { diff --git a/objectscript-lsp/src/bin/objectscript-mcp.rs b/objectscript-lsp/src/bin/objectscript-mcp.rs new file mode 100644 index 0000000..8140ba3 --- /dev/null +++ b/objectscript-lsp/src/bin/objectscript-mcp.rs @@ -0,0 +1,1526 @@ +use objectscript_core::common::{point_to_byte, position_to_point}; +use serde_json::{Map, Value, json}; +use std::collections::{HashMap, HashSet}; +use std::env; +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{self, Sender}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tower_lsp::lsp_types::{Position, Url}; +use walkdir::WalkDir; + +const MCP_PROTOCOL_VERSION: &str = "2024-11-05"; +const SERVER_NAME: &str = "objectscript-lsp-mcp"; +const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); +const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +const REFACTOR_DOCUMENT_COMMAND: &str = "objectscript.refactorDocument"; +const REFACTOR_WORKSPACE_COMMAND: &str = "objectscript.refactorWorkspace"; +const LEGACY_REFACTOR_WORKSPACE_DO_COMMAND: &str = "objectscript.refactorWorkspaceDottedDo"; + +fn main() { + let mut server = McpServer::new(); + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout(); + + for line in stdin.lock().lines() { + let line = match line { + Ok(line) => line, + Err(err) => { + eprintln!("failed to read MCP stdin: {err}"); + break; + } + }; + if line.trim().is_empty() { + continue; + } + + let request: Value = match serde_json::from_str(&line) { + Ok(value) => value, + Err(err) => { + eprintln!("failed to parse MCP message: {err}"); + continue; + } + }; + + if let Some(response) = server.handle_message(request) { + if let Err(err) = write_mcp_message(&mut stdout, &response) { + eprintln!("failed to write MCP response: {err}"); + break; + } + } + } +} + +struct McpServer { + root: PathBuf, + lsp: Option, +} + +impl McpServer { + fn new() -> Self { + Self { + root: default_workspace_root(), + lsp: None, + } + } + + fn handle_message(&mut self, request: Value) -> Option { + let method = request.get("method").and_then(Value::as_str).unwrap_or(""); + let id = request.get("id").cloned(); + + match method { + "initialize" => id.map(|id| { + json_rpc_result( + id, + json!({ + "protocolVersion": MCP_PROTOCOL_VERSION, + "capabilities": { + "tools": {} + }, + "serverInfo": { + "name": SERVER_NAME, + "version": SERVER_VERSION + } + }), + ) + }), + "notifications/initialized" => { + self.eager_initialize_workspace(); + None + } + "$/cancelRequest" => None, + "ping" => id.map(|id| json_rpc_result(id, json!({}))), + "tools/list" => id.map(|id| json_rpc_result(id, tools_list())), + "resources/list" => id.map(|id| json_rpc_result(id, json!({ "resources": [] }))), + "prompts/list" => id.map(|id| json_rpc_result(id, json!({ "prompts": [] }))), + "tools/call" => id.map(|id| { + let result = self.handle_tool_call(request.get("params").unwrap_or(&Value::Null)); + json_rpc_result(id, result) + }), + _ => id.map(|id| { + json_rpc_error( + id, + -32601, + format!("unknown MCP method: {method}"), + Value::Null, + ) + }), + } + } + + fn handle_tool_call(&mut self, params: &Value) -> Value { + let name = params.get("name").and_then(Value::as_str).unwrap_or(""); + let arguments = params.get("arguments").unwrap_or(&Value::Null); + + match name { + "objectscript_initialize_workspace" => { + match self.tool_initialize_workspace(arguments) { + Ok(result) => tool_json(result), + Err(err) => tool_error(err), + } + } + "objectscript_diagnostics" => match self.tool_diagnostics(arguments) { + Ok(result) => tool_json(result), + Err(err) => tool_error(err), + }, + "objectscript_goto_definition" => match self.tool_goto_definition(arguments) { + Ok(result) => tool_json(result), + Err(err) => tool_error(err), + }, + "objectscript_code_actions" => match self.tool_code_actions(arguments) { + Ok(result) => tool_json(result), + Err(err) => tool_error(err), + }, + "objectscript_execute_command" => match self.tool_execute_command(arguments) { + Ok(result) => tool_json(result), + Err(err) => tool_error(err), + }, + "objectscript_workspace_diagnostics" => { + match self.tool_workspace_diagnostics(arguments) { + Ok(result) => tool_json(result), + Err(err) => tool_error(err), + } + } + "objectscript_lsp_status" => tool_json(self.tool_status()), + _ => tool_error(format!("unknown tool: {name}")), + } + } + + fn tool_initialize_workspace(&mut self, arguments: &Value) -> Result { + let root = optional_string(arguments, "root") + .map(PathBuf::from) + .unwrap_or_else(default_workspace_root); + let root = absolute_path(root)?; + + self.ensure_lsp_for_root(root.clone())?; + + Ok(json!({ + "workspaceRoot": root.display().to_string(), + "workspaceUri": file_uri(&root)?.to_string(), + "lspBinary": lsp_binary_display(), + "initialized": true + })) + } + + fn tool_diagnostics(&mut self, arguments: &Value) -> Result { + let Some(file_path) = optional_string(arguments, "file_path") else { + return Err("missing required argument: file_path".to_string()); + }; + let root = optional_string(arguments, "root") + .map(PathBuf::from) + .unwrap_or_else(|| self.root.clone()); + let root = absolute_path(root)?; + let path = resolve_workspace_path(&root, file_path)?; + + self.ensure_lsp_for_root(root.clone())?; + let lsp = self + .lsp + .as_mut() + .ok_or_else(|| "LSP session was not initialized".to_string())?; + + let response = lsp.diagnostics(&path)?; + Ok(json!({ + "workspaceRoot": root.display().to_string(), + "filePath": path.display().to_string(), + "uri": file_uri(&path)?.to_string(), + "diagnosticReport": response + })) + } + + fn tool_goto_definition(&mut self, arguments: &Value) -> Result { + let Some(file_path) = optional_string(arguments, "file_path") else { + return Err("missing required argument: file_path".to_string()); + }; + let line = required_one_based_u32(arguments, "line")?; + let character = required_one_based_u32(arguments, "character")?; + let root = optional_string(arguments, "root") + .map(PathBuf::from) + .unwrap_or_else(|| self.root.clone()); + let root = absolute_path(root)?; + let path = resolve_workspace_path(&root, file_path)?; + + self.ensure_lsp_for_root(root.clone())?; + let lsp = self + .lsp + .as_mut() + .ok_or_else(|| "LSP session was not initialized".to_string())?; + + let response = lsp.goto_definition(&path, line - 1, character - 1)?; + Ok(json!({ + "workspaceRoot": root.display().to_string(), + "filePath": path.display().to_string(), + "uri": file_uri(&path)?.to_string(), + "position": { + "line": line, + "character": character + }, + "definitionResult": response + })) + } + + fn tool_code_actions(&mut self, arguments: &Value) -> Result { + let Some(file_path) = optional_string(arguments, "file_path") else { + return Err("missing required argument: file_path".to_string()); + }; + let root = optional_string(arguments, "root") + .map(PathBuf::from) + .unwrap_or_else(|| self.root.clone()); + let root = absolute_path(root)?; + let path = resolve_workspace_path(&root, file_path)?; + let range = code_action_range(arguments)?; + let only = optional_string_array(arguments, "only")? + .unwrap_or_else(|| vec!["refactor.rewrite".to_string()]); + let trigger_kind = optional_code_action_trigger_kind(arguments)?; + + self.ensure_lsp_for_root(root.clone())?; + let lsp = self + .lsp + .as_mut() + .ok_or_else(|| "LSP session was not initialized".to_string())?; + + let response = lsp.code_actions(&path, range, only, trigger_kind)?; + Ok(json!({ + "workspaceRoot": root.display().to_string(), + "filePath": path.display().to_string(), + "uri": file_uri(&path)?.to_string(), + "codeActions": response + })) + } + + fn tool_execute_command(&mut self, arguments: &Value) -> Result { + let Some(command) = optional_string(arguments, "command") else { + return Err("missing required argument: command".to_string()); + }; + if !is_allowed_lsp_command(command) { + return Err(format!("unsupported ObjectScript LSP command: {command}")); + } + let command_arguments = optional_array(arguments, "arguments")?.unwrap_or_default(); + let root = optional_string(arguments, "root") + .map(PathBuf::from) + .unwrap_or_else(|| self.root.clone()); + let root = absolute_path(root)?; + + self.ensure_lsp_for_root(root.clone())?; + let lsp = self + .lsp + .as_mut() + .ok_or_else(|| "LSP session was not initialized".to_string())?; + + for path in command_document_paths(&root, &command_arguments)? { + if is_supported_file(&path) { + lsp.open_document(&path)?; + } + } + + let response = lsp.execute_command(command, command_arguments)?; + Ok(json!({ + "workspaceRoot": root.display().to_string(), + "command": command, + "executeCommandResult": response.result, + "appliedEditPaths": response.applied_edit_paths, + "appliedEditCount": response.applied_edit_count + })) + } + + fn tool_workspace_diagnostics(&mut self, arguments: &Value) -> Result { + let root = optional_string(arguments, "root") + .map(PathBuf::from) + .unwrap_or_else(|| self.root.clone()); + let root = absolute_path(root)?; + let include_clean = optional_bool(arguments, "include_clean"); + let max_files = optional_usize(arguments, "max_files")?; + + self.ensure_lsp_for_root(root.clone())?; + let lsp = self + .lsp + .as_mut() + .ok_or_else(|| "LSP session was not initialized".to_string())?; + + let mut files_checked = 0usize; + let mut files_with_diagnostics = 0usize; + let mut diagnostics_count = 0usize; + let mut files = Vec::new(); + let mut clean_files = Vec::new(); + let mut errors = Vec::new(); + let mut truncated = false; + + for entry in WalkDir::new(&root).into_iter() { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + errors.push(json!({ + "path": err.path().map(|path| path.display().to_string()), + "error": err.to_string() + })); + continue; + } + }; + + let path = entry.path(); + if !path.is_file() || !is_supported_file(path) { + continue; + } + + if let Some(max_files) = max_files { + if files_checked >= max_files { + truncated = true; + break; + } + } + + files_checked += 1; + let label = workspace_relative_path(&root, path); + match lsp.diagnostics(path) { + Ok(report) => { + let items = diagnostic_items(&report); + let count = items.len(); + diagnostics_count += count; + if count > 0 { + files_with_diagnostics += 1; + files.push(json!({ + "path": label, + "uri": file_uri(path)?.to_string(), + "diagnosticsCount": count, + "diagnosticReport": report + })); + } else if include_clean { + clean_files.push(label); + } + } + Err(err) => { + errors.push(json!({ + "path": label, + "error": err + })); + } + } + } + + Ok(json!({ + "workspaceRoot": root.display().to_string(), + "workspaceUri": file_uri(&root)?.to_string(), + "filesChecked": files_checked, + "filesWithDiagnostics": files_with_diagnostics, + "diagnosticsCount": diagnostics_count, + "truncated": truncated, + "files": files, + "cleanFiles": if include_clean { Value::Array(clean_files.into_iter().map(Value::String).collect()) } else { Value::Null }, + "errors": errors + })) + } + + fn tool_status(&self) -> Value { + json!({ + "workspaceRoot": self.root.display().to_string(), + "workspaceUri": file_uri(&self.root).map(|uri| uri.to_string()).ok(), + "lspBinary": lsp_binary_display(), + "initialized": self.lsp.is_some() + }) + } + + fn ensure_lsp_for_root(&mut self, root: PathBuf) -> Result<(), String> { + if self.root != root { + self.lsp = None; + self.root = root; + } + + if self.lsp.is_none() { + self.lsp = Some(LspSession::start(self.root.clone())?); + } + + Ok(()) + } + + fn eager_initialize_workspace(&mut self) { + let root = match absolute_path(default_workspace_root()) { + Ok(root) => root, + Err(err) => { + eprintln!("failed to resolve MCP workspace root during eager init: {err}"); + return; + } + }; + + if let Err(err) = self.ensure_lsp_for_root(root) { + eprintln!("failed to eagerly initialize objectscript-lsp: {err}"); + } + } +} + +struct LspSession { + root: PathBuf, + root_uri: String, + child: Child, + writer: Arc>, + pending: Arc>>>, + applied_edit_paths: Arc>>, + next_id: AtomicU64, + document_versions: HashMap, +} + +struct ExecuteCommandResult { + result: Value, + applied_edit_count: usize, + applied_edit_paths: Vec, +} + +#[derive(Clone)] +struct TextReplacement { + start_byte: usize, + end_byte: usize, + new_text: String, +} + +impl LspSession { + fn start(root: PathBuf) -> Result { + let root_uri = file_uri(&root)?.to_string(); + let mut child = Command::new(resolve_lsp_binary()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|err| format!("failed to start objectscript-lsp: {err}"))?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| "failed to open objectscript-lsp stdin".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "failed to open objectscript-lsp stdout".to_string())?; + + if let Some(stderr) = child.stderr.take() { + std::thread::spawn(move || forward_child_stderr(stderr)); + } + + let writer = Arc::new(Mutex::new(stdin)); + let pending = Arc::new(Mutex::new(HashMap::new())); + let applied_edit_paths = Arc::new(Mutex::new(Vec::new())); + std::thread::spawn({ + let writer = Arc::clone(&writer); + let pending = Arc::clone(&pending); + let applied_edit_paths = Arc::clone(&applied_edit_paths); + let root = root.clone(); + let root_uri = root_uri.clone(); + let root_name = workspace_name(&root); + move || { + read_lsp_messages( + stdout, + writer, + pending, + applied_edit_paths, + root, + root_uri, + root_name, + ) + } + }); + + let session = Self { + root, + root_uri, + child, + writer, + pending, + applied_edit_paths, + next_id: AtomicU64::new(1), + document_versions: HashMap::new(), + }; + + session.initialize()?; + Ok(session) + } + + fn initialize(&self) -> Result<(), String> { + self.send_request( + "initialize", + json!({ + "processId": null, + "rootUri": self.root_uri, + "workspaceFolders": [ + { + "uri": self.root_uri, + "name": workspace_name(&self.root) + } + ], + "capabilities": { + "workspace": { + "applyEdit": true, + "workspaceFolders": true, + "didChangeWatchedFiles": { + "dynamicRegistration": true + } + }, + "textDocument": { + "codeAction": { + "dynamicRegistration": false + }, + "diagnostic": { + "dynamicRegistration": false + } + } + }, + "initializationOptions": {} + }), + )?; + self.send_notification("initialized", json!({}))?; + Ok(()) + } + + fn open_document(&mut self, path: &Path) -> Result<(), String> { + let uri = file_uri(path)?.to_string(); + let text = std::fs::read_to_string(path) + .map_err(|err| format!("failed to read {}: {err}", path.display()))?; + let version = { + let entry = self + .document_versions + .entry(path.to_path_buf()) + .or_insert(0); + *entry += 1; + *entry + }; + self.send_notification( + "textDocument/didOpen", + json!({ + "textDocument": { + "uri": uri, + "languageId": language_id(path)?, + "version": version, + "text": text + } + }), + ) + } + + fn diagnostics(&mut self, path: &Path) -> Result { + self.open_document(path)?; + + self.send_request( + "textDocument/diagnostic", + json!({ + "textDocument": { + "uri": file_uri(path)?.to_string() + } + }), + ) + } + + fn goto_definition(&mut self, path: &Path, line: u32, character: u32) -> Result { + self.open_document(path)?; + + self.send_request( + "textDocument/definition", + json!({ + "textDocument": { + "uri": file_uri(path)?.to_string() + }, + "position": { + "line": line, + "character": character + } + }), + ) + } + + fn code_actions( + &mut self, + path: &Path, + range: Value, + only: Vec, + trigger_kind: u64, + ) -> Result { + self.open_document(path)?; + + self.send_request( + "textDocument/codeAction", + json!({ + "textDocument": { + "uri": file_uri(path)?.to_string() + }, + "range": range, + "context": { + "diagnostics": [], + "only": only, + "triggerKind": trigger_kind + } + }), + ) + } + + fn execute_command( + &mut self, + command: &str, + arguments: Vec, + ) -> Result { + let result = self.send_request( + "workspace/executeCommand", + json!({ + "command": command, + "arguments": arguments + }), + )?; + let edited_paths = self.drain_applied_edit_paths()?; + for path in &edited_paths { + self.sync_document(path)?; + } + + Ok(ExecuteCommandResult { + result, + applied_edit_count: edited_paths.len(), + applied_edit_paths: edited_paths + .iter() + .map(|path| path.display().to_string()) + .collect(), + }) + } + + fn sync_document(&mut self, path: &Path) -> Result<(), String> { + let text = std::fs::read_to_string(path).map_err(|err| { + format!( + "failed to read {} after applying edits: {err}", + path.display() + ) + })?; + let version = { + let entry = self + .document_versions + .entry(path.to_path_buf()) + .or_insert(0); + *entry += 1; + *entry + }; + + self.send_notification( + "textDocument/didChange", + json!({ + "textDocument": { + "uri": file_uri(path)?.to_string(), + "version": version + }, + "contentChanges": [ + { + "text": text + } + ] + }), + ) + } + + fn drain_applied_edit_paths(&self) -> Result, String> { + let mut paths = self + .applied_edit_paths + .lock() + .map_err(|_| "applied edit path lock poisoned".to_string())?; + let mut seen = HashSet::new(); + let mut unique_paths = Vec::new(); + for path in paths.drain(..) { + if seen.insert(path.clone()) { + unique_paths.push(path); + } + } + Ok(unique_paths) + } + + fn send_request(&self, method: &str, params: Value) -> Result { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = mpsc::channel(); + self.pending + .lock() + .map_err(|_| "pending request map lock poisoned".to_string())? + .insert(id, tx); + + let message = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + }); + + if let Err(err) = write_lsp_message(&self.writer, &message) { + let _ = self.pending.lock().map(|mut pending| pending.remove(&id)); + return Err(err); + } + + let response = rx + .recv_timeout(DEFAULT_REQUEST_TIMEOUT) + .map_err(|_| format!("timed out waiting for LSP response to {method}"))?; + + if let Some(error) = response.get("error") { + return Err(format!("LSP request {method} failed: {error}")); + } + + Ok(response.get("result").cloned().unwrap_or(Value::Null)) + } + + fn send_notification(&self, method: &str, params: Value) -> Result<(), String> { + write_lsp_message( + &self.writer, + &json!({ + "jsonrpc": "2.0", + "method": method, + "params": params + }), + ) + } +} + +impl Drop for LspSession { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn read_lsp_messages( + stdout: impl Read, + writer: Arc>, + pending: Arc>>>, + applied_edit_paths: Arc>>, + root: PathBuf, + root_uri: String, + root_name: String, +) { + let mut reader = BufReader::new(stdout); + + loop { + let Some(content_length) = read_content_length(&mut reader) else { + break; + }; + + let mut body = vec![0; content_length]; + if let Err(err) = reader.read_exact(&mut body) { + if err.kind() == std::io::ErrorKind::UnexpectedEof { + break; + } + eprintln!("failed to read LSP body: {err}"); + break; + } + + let Ok(message) = serde_json::from_slice::(&body) else { + eprintln!("failed to parse LSP message"); + continue; + }; + + if message.get("method").is_some() && message.get("id").is_some() { + respond_to_lsp_request( + &writer, + &message, + &root, + &root_uri, + &root_name, + &applied_edit_paths, + ); + } else if let Some(id) = message.get("id").and_then(Value::as_u64) { + if let Ok(mut pending) = pending.lock() { + if let Some(tx) = pending.remove(&id) { + let _ = tx.send(message); + } + } + } + } +} + +fn read_content_length(reader: &mut BufReader) -> Option { + let mut content_length = None; + + loop { + let mut line = String::new(); + let bytes = reader.read_line(&mut line).ok()?; + if bytes == 0 { + return None; + } + + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() { + break; + } + + if let Some(value) = trimmed.strip_prefix("Content-Length:") { + content_length = value.trim().parse::().ok(); + } + } + + content_length +} + +fn respond_to_lsp_request( + writer: &Arc>, + request: &Value, + root: &Path, + root_uri: &str, + root_name: &str, + applied_edit_paths: &Arc>>, +) { + let Some(id) = request.get("id").cloned() else { + return; + }; + let method = request.get("method").and_then(Value::as_str).unwrap_or(""); + let result = match method { + "workspace/workspaceFolders" => json!([ + { + "uri": root_uri, + "name": root_name + } + ]), + "workspace/applyEdit" => match apply_workspace_edit(root, request, applied_edit_paths) { + Ok(_) => json!({ + "applied": true + }), + Err(err) => json!({ + "applied": false, + "failureReason": err + }), + }, + _ => Value::Null, + }; + + let response = json!({ + "jsonrpc": "2.0", + "id": id, + "result": result + }); + if let Err(err) = write_lsp_message(writer, &response) { + eprintln!("failed to respond to LSP request {method}: {err}"); + } +} + +fn write_lsp_message(writer: &Arc>, message: &Value) -> Result<(), String> { + let body = serde_json::to_vec(message).map_err(|err| err.to_string())?; + let header = format!("Content-Length: {}\r\n\r\n", body.len()); + let mut writer = writer + .lock() + .map_err(|_| "LSP stdin lock poisoned".to_string())?; + writer + .write_all(header.as_bytes()) + .map_err(|err| format!("failed to write LSP header: {err}"))?; + writer + .write_all(&body) + .map_err(|err| format!("failed to write LSP body: {err}"))?; + writer + .flush() + .map_err(|err| format!("failed to flush LSP message: {err}")) +} + +fn forward_child_stderr(stderr: impl Read) { + let reader = BufReader::new(stderr); + for line in reader.lines().map_while(Result::ok) { + eprintln!("[objectscript-lsp] {line}"); + } +} + +fn write_mcp_message(stdout: &mut impl Write, response: &Value) -> std::io::Result<()> { + serde_json::to_writer(&mut *stdout, response)?; + stdout.write_all(b"\n")?; + stdout.flush() +} + +fn json_rpc_result(id: Value, result: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "result": result + }) +} + +fn json_rpc_error(id: Value, code: i64, message: String, data: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message, + "data": data + } + }) +} + +fn tool_json(value: Value) -> Value { + json!({ + "content": [ + { + "type": "text", + "text": serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string()) + } + ], + "isError": false + }) +} + +fn tool_error(message: String) -> Value { + json!({ + "content": [ + { + "type": "text", + "text": message + } + ], + "isError": true + }) +} + +fn tools_list() -> Value { + json!({ + "tools": [ + { + "name": "objectscript_initialize_workspace", + "description": "Start objectscript-lsp and initialize it for the Claude project directory, or for the optional root path.", + "inputSchema": { + "type": "object", + "properties": { + "root": { + "type": "string", + "description": "Optional workspace root. Defaults to CLAUDE_PROJECT_DIR, then the MCP process current directory." + } + } + } + }, + { + "name": "objectscript_diagnostics", + "description": "Open an ObjectScript/XML file in objectscript-lsp and return pull diagnostics from textDocument/diagnostic.", + "inputSchema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to a .cls, .mac, .inc, .rtn, .int, or .xml file. Relative paths resolve from the workspace root." + }, + "root": { + "type": "string", + "description": "Optional workspace root override." + } + }, + "required": ["file_path"] + } + }, + { + "name": "objectscript_goto_definition", + "description": "Open an ObjectScript/XML file in objectscript-lsp and return the textDocument/definition result for a 1-based source position.", + "inputSchema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to a .cls, .mac, .inc, .rtn, .int, or .xml file. Relative paths resolve from the workspace root." + }, + "line": { + "type": "integer", + "minimum": 1, + "description": "1-based line number for the symbol reference." + }, + "character": { + "type": "integer", + "minimum": 1, + "description": "1-based character/column for the symbol reference." + }, + "root": { + "type": "string", + "description": "Optional workspace root override." + } + }, + "required": ["file_path", "line", "character"] + } + }, + { + "name": "objectscript_code_actions", + "description": "Return ObjectScript LSP textDocument/codeAction results for a file. Defaults to refactor.rewrite actions.", + "inputSchema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to a .cls, .mac, .inc, .rtn, .int, or .xml file. Relative paths resolve from the workspace root." + }, + "line": { + "type": "integer", + "minimum": 1, + "description": "Optional 1-based cursor line. Used as a collapsed range when start/end are omitted." + }, + "character": { + "type": "integer", + "minimum": 1, + "description": "Optional 1-based cursor character/column. Used with line as a collapsed range." + }, + "start_line": { + "type": "integer", + "minimum": 1, + "description": "Optional 1-based start line for the code-action range." + }, + "start_character": { + "type": "integer", + "minimum": 1, + "description": "Optional 1-based start character/column for the code-action range." + }, + "end_line": { + "type": "integer", + "minimum": 1, + "description": "Optional 1-based end line for the code-action range." + }, + "end_character": { + "type": "integer", + "minimum": 1, + "description": "Optional 1-based end character/column for the code-action range." + }, + "only": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional LSP code-action kinds. Defaults to [\"refactor.rewrite\"]." + }, + "trigger_kind": { + "type": "string", + "enum": ["invoked", "automatic"], + "description": "Optional LSP code-action trigger kind. Defaults to invoked." + }, + "root": { + "type": "string", + "description": "Optional workspace root override." + } + }, + "required": ["file_path"] + } + }, + { + "name": "objectscript_execute_command", + "description": "Execute an allowlisted ObjectScript LSP command returned by a code action and apply any workspace edits to files under the workspace root.", + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "ObjectScript LSP command to execute, such as objectscript.refactorDocument or objectscript.refactorWorkspace." + }, + "arguments": { + "type": "array", + "description": "Command arguments exactly as returned from objectscript_code_actions." + }, + "root": { + "type": "string", + "description": "Optional workspace root override." + } + }, + "required": ["command"] + } + }, + { + "name": "objectscript_workspace_diagnostics", + "description": "Run pull diagnostics for every supported ObjectScript/XML file under the workspace root.", + "inputSchema": { + "type": "object", + "properties": { + "root": { + "type": "string", + "description": "Optional workspace root override. Defaults to the initialized workspace root." + }, + "include_clean": { + "type": "boolean", + "description": "When true, include clean file paths in cleanFiles. Defaults to false." + }, + "max_files": { + "type": "integer", + "minimum": 1, + "description": "Optional safety limit for the number of supported files to check." + } + } + } + }, + { + "name": "objectscript_lsp_status", + "description": "Show the workspace root, LSP binary path, and whether the MCP bridge has initialized objectscript-lsp.", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ] + }) +} + +fn optional_string<'a>(arguments: &'a Value, key: &str) -> Option<&'a str> { + arguments.get(key).and_then(Value::as_str) +} + +fn optional_bool(arguments: &Value, key: &str) -> bool { + arguments.get(key).and_then(Value::as_bool).unwrap_or(false) +} + +fn optional_usize(arguments: &Value, key: &str) -> Result, String> { + let Some(value) = arguments.get(key) else { + return Ok(None); + }; + let Some(value) = value.as_u64() else { + return Err(format!("{key} must be a positive integer")); + }; + if value == 0 { + return Err(format!("{key} must be greater than zero")); + } + usize::try_from(value) + .map(Some) + .map_err(|_| format!("{key} is too large")) +} + +fn optional_array(arguments: &Value, key: &str) -> Result>, String> { + let Some(value) = arguments.get(key) else { + return Ok(None); + }; + value + .as_array() + .cloned() + .map(Some) + .ok_or_else(|| format!("{key} must be an array")) +} + +fn optional_string_array(arguments: &Value, key: &str) -> Result>, String> { + let Some(values) = optional_array(arguments, key)? else { + return Ok(None); + }; + let mut strings = Vec::with_capacity(values.len()); + for value in values { + let Some(string) = value.as_str() else { + return Err(format!("{key} must contain only strings")); + }; + strings.push(string.to_string()); + } + Ok(Some(strings)) +} + +fn required_one_based_u32(arguments: &Value, key: &str) -> Result { + let Some(value) = arguments.get(key) else { + return Err(format!("missing required argument: {key}")); + }; + let Some(value) = value.as_u64() else { + return Err(format!("{key} must be a positive integer")); + }; + if value == 0 { + return Err(format!("{key} must be greater than zero")); + } + u32::try_from(value).map_err(|_| format!("{key} is too large")) +} + +fn optional_one_based_u32(arguments: &Value, key: &str) -> Result, String> { + let Some(value) = arguments.get(key) else { + return Ok(None); + }; + let Some(value) = value.as_u64() else { + return Err(format!("{key} must be a positive integer")); + }; + if value == 0 { + return Err(format!("{key} must be greater than zero")); + } + u32::try_from(value) + .map(Some) + .map_err(|_| format!("{key} is too large")) +} + +fn optional_code_action_trigger_kind(arguments: &Value) -> Result { + match optional_string(arguments, "trigger_kind").unwrap_or("invoked") { + "invoked" => Ok(1), + "automatic" => Ok(2), + trigger_kind => Err(format!( + "trigger_kind must be either \"invoked\" or \"automatic\", got {trigger_kind:?}" + )), + } +} + +fn code_action_range(arguments: &Value) -> Result { + let line = optional_one_based_u32(arguments, "line")?.unwrap_or(1); + let character = optional_one_based_u32(arguments, "character")?.unwrap_or(1); + let start_line = optional_one_based_u32(arguments, "start_line")?.unwrap_or(line); + let start_character = + optional_one_based_u32(arguments, "start_character")?.unwrap_or(character); + let end_line = optional_one_based_u32(arguments, "end_line")?.unwrap_or(start_line); + let end_character = + optional_one_based_u32(arguments, "end_character")?.unwrap_or(start_character); + + Ok(json!({ + "start": { + "line": start_line - 1, + "character": start_character - 1 + }, + "end": { + "line": end_line - 1, + "character": end_character - 1 + } + })) +} + +fn is_allowed_lsp_command(command: &str) -> bool { + matches!( + command, + REFACTOR_DOCUMENT_COMMAND + | REFACTOR_WORKSPACE_COMMAND + | LEGACY_REFACTOR_WORKSPACE_DO_COMMAND + ) +} + +fn command_document_paths(root: &Path, arguments: &[Value]) -> Result, String> { + let mut paths = Vec::new(); + let mut seen = HashSet::new(); + for argument in arguments { + let Some(uri) = argument.as_str() else { + continue; + }; + if !uri.starts_with("file://") { + continue; + } + let path = path_from_uri_under_root(root, uri)?; + if seen.insert(path.clone()) { + paths.push(path); + } + } + Ok(paths) +} + +fn apply_workspace_edit( + root: &Path, + request: &Value, + applied_edit_paths: &Arc>>, +) -> Result { + let edit = request + .get("params") + .and_then(|params| params.get("edit")) + .ok_or_else(|| "workspace/applyEdit request did not include params.edit".to_string())?; + + let mut changed_paths = Vec::new(); + let mut edit_count = 0usize; + + if let Some(changes) = edit.get("changes").and_then(Value::as_object) { + edit_count += apply_changes_map(root, changes, &mut changed_paths)?; + } + + if let Some(document_changes) = edit.get("documentChanges").and_then(Value::as_array) { + edit_count += apply_document_changes(root, document_changes, &mut changed_paths)?; + } + + if !changed_paths.is_empty() { + applied_edit_paths + .lock() + .map_err(|_| "applied edit path lock poisoned".to_string())? + .extend(changed_paths); + } + + Ok(edit_count) +} + +fn apply_changes_map( + root: &Path, + changes: &Map, + changed_paths: &mut Vec, +) -> Result { + let mut edit_count = 0usize; + for (uri, edits) in changes { + edit_count += apply_uri_text_edits(root, uri, edits, changed_paths)?; + } + Ok(edit_count) +} + +fn apply_document_changes( + root: &Path, + document_changes: &[Value], + changed_paths: &mut Vec, +) -> Result { + let mut edit_count = 0usize; + for change in document_changes { + if let Some(kind) = change.get("kind").and_then(Value::as_str) { + return Err(format!( + "workspace/applyEdit does not support {kind:?} document changes" + )); + } + let uri = change + .get("textDocument") + .and_then(|text_document| text_document.get("uri")) + .and_then(Value::as_str) + .ok_or_else(|| "documentChanges item is missing textDocument.uri".to_string())?; + let edits = change + .get("edits") + .ok_or_else(|| "documentChanges item is missing edits".to_string())?; + edit_count += apply_uri_text_edits(root, uri, edits, changed_paths)?; + } + Ok(edit_count) +} + +fn apply_uri_text_edits( + root: &Path, + uri: &str, + edits: &Value, + changed_paths: &mut Vec, +) -> Result { + let edits = edits + .as_array() + .ok_or_else(|| format!("text edits for {uri} must be an array"))?; + if edits.is_empty() { + return Ok(0); + } + + let path = path_from_uri_under_root(root, uri)?; + apply_text_edits_to_file(&path, edits)?; + changed_paths.push(path); + Ok(edits.len()) +} + +fn path_from_uri_under_root(root: &Path, uri: &str) -> Result { + let url = Url::parse(uri).map_err(|err| format!("invalid file URI {uri:?}: {err}"))?; + let path = url + .to_file_path() + .map_err(|_| format!("URI is not a file path: {uri}"))?; + if !path.exists() { + return Err(format!( + "cannot apply edit to missing file: {}", + path.display() + )); + } + + let canonical_root = std::fs::canonicalize(root) + .map_err(|err| format!("failed to canonicalize root {}: {err}", root.display()))?; + let canonical_path = std::fs::canonicalize(&path) + .map_err(|err| format!("failed to canonicalize edit path {}: {err}", path.display()))?; + if !canonical_path.starts_with(&canonical_root) { + return Err(format!( + "refusing to apply edit outside workspace root: {}", + path.display() + )); + } + + Ok(path) +} + +fn apply_text_edits_to_file(path: &Path, edits: &[Value]) -> Result<(), String> { + let original = std::fs::read_to_string(path) + .map_err(|err| format!("failed to read {} for text edits: {err}", path.display()))?; + let mut replacements = Vec::with_capacity(edits.len()); + for edit in edits { + replacements.push(parse_text_replacement(&original, edit)?); + } + + replacements.sort_by(|left, right| { + right + .start_byte + .cmp(&left.start_byte) + .then_with(|| right.end_byte.cmp(&left.end_byte)) + }); + + let mut next_start = usize::MAX; + for replacement in &replacements { + if replacement.end_byte > next_start { + return Err(format!("overlapping text edits for {}", path.display())); + } + next_start = replacement.start_byte; + } + + let mut updated = original; + for replacement in replacements { + updated.replace_range( + replacement.start_byte..replacement.end_byte, + replacement.new_text.as_str(), + ); + } + std::fs::write(path, updated) + .map_err(|err| format!("failed to write text edits to {}: {err}", path.display())) +} + +fn parse_text_replacement(original: &str, edit: &Value) -> Result { + let range = edit + .get("range") + .ok_or_else(|| "text edit is missing range".to_string())?; + let start = parse_zero_based_position( + range + .get("start") + .ok_or_else(|| "text edit range is missing start".to_string())?, + "range.start", + )?; + let end = parse_zero_based_position( + range + .get("end") + .ok_or_else(|| "text edit range is missing end".to_string())?, + "range.end", + )?; + let new_text = edit + .get("newText") + .and_then(Value::as_str) + .ok_or_else(|| "text edit is missing newText".to_string())? + .to_string(); + + let start_byte = point_to_byte(original, position_to_point(original, start)); + let end_byte = point_to_byte(original, position_to_point(original, end)); + if start_byte > end_byte { + return Err("text edit range start is after range end".to_string()); + } + + Ok(TextReplacement { + start_byte, + end_byte, + new_text, + }) +} + +fn parse_zero_based_position(value: &Value, label: &str) -> Result { + let line = value + .get("line") + .and_then(Value::as_u64) + .ok_or_else(|| format!("{label}.line must be a non-negative integer"))?; + let character = value + .get("character") + .and_then(Value::as_u64) + .ok_or_else(|| format!("{label}.character must be a non-negative integer"))?; + Ok(Position::new( + u32::try_from(line).map_err(|_| format!("{label}.line is too large"))?, + u32::try_from(character).map_err(|_| format!("{label}.character is too large"))?, + )) +} + +fn default_workspace_root() -> PathBuf { + env::var_os("CLAUDE_PROJECT_DIR") + .map(PathBuf::from) + .or_else(|| env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn absolute_path(path: PathBuf) -> Result { + if path.is_absolute() { + Ok(path) + } else { + env::current_dir() + .map(|cwd| cwd.join(path)) + .map_err(|err| format!("failed to resolve current directory: {err}")) + } +} + +fn resolve_workspace_path(root: &Path, file_path: &str) -> Result { + let path = PathBuf::from(file_path); + let path = if path.is_absolute() { + path + } else { + root.join(path) + }; + if !path.exists() { + return Err(format!("file does not exist: {}", path.display())); + } + Ok(path) +} + +fn file_uri(path: &Path) -> Result { + Url::from_file_path(path) + .map_err(|_| format!("failed to convert path to URI: {}", path.display())) +} + +fn workspace_name(root: &Path) -> String { + root.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("workspace") + .to_string() +} + +fn language_id(path: &Path) -> Result<&'static str, String> { + match path.extension().and_then(|ext| ext.to_str()) { + Some("cls") => Ok("objectscript_udl"), + Some("inc") | Some("rtn") | Some("mac") | Some("int") => Ok("objectscript_routine"), + Some("xml") => Ok("xml"), + _ => Err(format!( + "unsupported file extension for diagnostics: {}", + path.display() + )), + } +} + +fn is_supported_file(path: &Path) -> bool { + language_id(path).is_ok() +} + +fn workspace_relative_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .display() + .to_string() +} + +fn diagnostic_items(report: &Value) -> Vec { + report + .get("items") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() +} + +fn resolve_lsp_binary() -> PathBuf { + if let Some(binary) = env::var_os("OBJECTSCRIPT_LSP_BINARY") { + return PathBuf::from(binary); + } + + if let Ok(current_exe) = env::current_exe() { + if let Some(parent) = current_exe.parent() { + let sibling = parent.join("objectscript-lsp"); + if sibling.exists() { + return sibling; + } + } + } + + PathBuf::from("objectscript-lsp") +} + +fn lsp_binary_display() -> String { + resolve_lsp_binary().display().to_string() +} diff --git a/objectscript-lsp/src/common.rs b/objectscript-lsp/src/common.rs index 2a7706f..25faac5 100644 --- a/objectscript-lsp/src/common.rs +++ b/objectscript-lsp/src/common.rs @@ -32,13 +32,22 @@ pub fn diagnostic_message(node: Node, error_text: &str) -> Option { "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 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)); + 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 Some(format!( + "Syntax Error: Unexpected, {} after an expression. Expected a binary operator or end of SET command", + error_text + )); } _ => return None, diff --git a/objectscript-lsp/src/lsp.rs b/objectscript-lsp/src/lsp.rs index 4e8e566..5f068db 100644 --- a/objectscript-lsp/src/lsp.rs +++ b/objectscript-lsp/src/lsp.rs @@ -10,26 +10,30 @@ use objectscript_core::common::{ }; use objectscript_core::config::Config; use objectscript_core::parse_structures::{ClassId, FileType, MemberType, RefactorLevel}; -use objectscript_core::workspace::ProjectState; +use objectscript_core::workspace::{ProjectData, ProjectState}; use serde_json; use serde_json::Value; use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::Ordering; 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, + CodeActionResponse, CodeActionTriggerKind, Command, ConfigurationItem, Diagnostic, + DiagnosticOptions, DiagnosticServerCapabilities, DiagnosticSeverity, + DidChangeConfigurationParams, 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, + Url, WatchKind, WorkspaceDiagnosticParams, WorkspaceDiagnosticReport, + WorkspaceDiagnosticReportResult, WorkspaceDocumentDiagnosticReport, WorkspaceEdit, + WorkspaceFullDocumentDiagnosticReport, }; use tree_sitter::{InputEdit, Parser, Point, Tree}; use tree_sitter_objectscript_playground::LANGUAGE_OBJECTSCRIPT; @@ -108,22 +112,17 @@ fn refactor_title(refactor_level: RefactorLevel, scope: &str) -> String { } fn selectable_document_refactor_levels(file_type: FileType) -> &'static [RefactorLevel] { - const ROUTINE_LEVELS: [RefactorLevel; 4] = [ + const OBJECTSCRIPT_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::Routine => &OBJECTSCRIPT_LEVELS, + FileType::Cls => &OBJECTSCRIPT_LEVELS, FileType::Xml => &XML_LEVELS, } } @@ -192,6 +191,55 @@ fn push_xml_injected_objectscript_diagnostics( } } +fn push_project_semantic_diagnostics( + diagnostics: &mut Vec, + data: &ProjectData, + uri: &Url, +) { + if let Some(class_diagnostics) = data.other_class_diagnostics.get(uri) { + diagnostics.extend(class_diagnostics.iter().cloned()); + } + + for diagnostics_by_url in data.inheritance_diagonstics.values() { + if let Some(diagnostic) = diagnostics_by_url.get(uri) { + diagnostics.push(diagnostic.clone()); + } + } + + for diagnostics_by_url in data.method_reference_diagnostics.values() { + if let Some(diagnostic) = diagnostics_by_url.get(uri) { + diagnostics.push(diagnostic.clone()); + } + } +} + +fn collect_document_diagnostics(data: &ProjectData, uri: &Url) -> Option> { + let document = data.documents.get(uri)?; + if !data.config.enable_lint { + return Some(Vec::new()); + } + + let mut diagnostics = Vec::new(); + let content = document.content.as_str(); + + push_host_syntax_diagnostics( + &mut diagnostics, + content, + &document.tree, + document.file_type.clone(), + ); + + if document.file_type == FileType::Xml { + push_xml_injected_objectscript_diagnostics(&mut diagnostics, content, &document.tree); + } + + if data.config.enable_strict_mode { + push_project_semantic_diagnostics(&mut diagnostics, data, uri); + } + + Some(diagnostics) +} + fn build_refactor_command( command: &str, uri: &tower_lsp::lsp_types::Url, @@ -295,8 +343,8 @@ fn build_caps(cfg: &Config) -> ServerCapabilities { })), diagnostic_provider: Some(DiagnosticServerCapabilities::Options(DiagnosticOptions { identifier: None, - inter_file_dependencies: false, - workspace_diagnostics: false, + inter_file_dependencies: true, + workspace_diagnostics: true, work_done_progress_options: Default::default(), })), execute_command_provider: Some(ExecuteCommandOptions { @@ -317,14 +365,127 @@ fn build_caps(cfg: &Config) -> ServerCapabilities { } } +impl BackendWrapper { + fn apply_config_to_projects(&self, config: &Config) -> bool { + let projects: Vec<_> = self.0.projects.read().values().cloned().collect(); + let mut changed = false; + + for project in projects { + let mut data = project.data.write(); + if data.config != *config { + data.config = config.clone(); + changed = true; + } + } + + changed + } + + async fn request_client_config(&self) -> Option { + if !self.0.configuration_supported.load(Ordering::Relaxed) { + return None; + } + + let values = match self + .0 + .client + .configuration(vec![ + ConfigurationItem { + scope_uri: None, + section: Some("objectscript".to_string()), + }, + ConfigurationItem { + scope_uri: None, + section: Some("objectscript-lsp".to_string()), + }, + ConfigurationItem { + scope_uri: None, + section: None, + }, + ]) + .await + { + Ok(values) => values, + Err(error) => { + self.0 + .client + .log_message( + MessageType::WARNING, + format!("Failed to request ObjectScript configuration: {error}"), + ) + .await; + return None; + } + }; + + for value in values { + if value.is_null() { + continue; + } + if value.as_object().is_some_and(|object| object.is_empty()) { + continue; + } + + match Config::from_lsp_value_if_present(value) { + Ok(Some(config)) => return Some(config), + Ok(None) => continue, + Err(error) => { + self.0 + .client + .log_message( + MessageType::WARNING, + format!( + "Failed to parse requested ObjectScript configuration: {error}" + ), + ) + .await; + } + } + } + + None + } +} + #[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(); + let diagnostic_refresh_supported = params + .capabilities + .workspace + .as_ref() + .and_then(|workspace| workspace.diagnostic.as_ref()) + .and_then(|diagnostic| diagnostic.refresh_support) + .unwrap_or(false); + self.0 + .set_diagnostic_refresh_supported(diagnostic_refresh_supported); + let configuration_supported = params + .capabilities + .workspace + .as_ref() + .and_then(|workspace| workspace.configuration) + .unwrap_or(false); + self.0.set_configuration_supported(configuration_supported); + + // Negotiate with the client to set config for formatting, lint, snippets, and diagnostics. + let negotiations = match params.initialization_options { + Some(value) => match Config::from_lsp_value(value) { + Ok(config) => config, + Err(error) => { + self.0 + .client + .log_message( + MessageType::WARNING, + format!( + "Failed to parse ObjectScript initializationOptions; using defaults: {error}" + ), + ) + .await; + Config::default() + } + }, + None => Config::default(), + }; if let Some(folders) = params.workspace_folders { for folder in folders { @@ -337,6 +498,7 @@ impl LanguageServer for BackendWrapper { }; // create projectState and set the projectRoot let state = ProjectState::new(); + state.data.write().config = negotiations.clone(); if state.project_root_path.set(Some(project_root)).is_err() { self.0 .client @@ -405,6 +567,39 @@ impl LanguageServer for BackendWrapper { }); } } + + if let Some(config) = self.request_client_config().await { + if self.apply_config_to_projects(&config) { + self.0.refresh_workspace_diagnostics_if_supported().await; + } + } + } + + async fn did_change_configuration(&self, params: DidChangeConfigurationParams) { + let config = if let Some(config) = self.request_client_config().await { + config + } else { + match Config::from_lsp_value_if_present(params.settings) { + Ok(Some(config)) => config, + Ok(None) => return, + Err(error) => { + self.0 + .client + .log_message( + MessageType::WARNING, + format!( + "Failed to parse ObjectScript configuration change; keeping existing config: {error}" + ), + ) + .await; + return; + } + } + }; + + if self.apply_config_to_projects(&config) { + self.0.refresh_workspace_diagnostics_if_supported().await; + } } async fn code_action(&self, params: CodeActionParams) -> Result> { @@ -478,6 +673,7 @@ impl LanguageServer for BackendWrapper { } async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) { + let mut changed_any = false; for change in params.changes { let Some(file_type) = file_type_from_path(change.uri.path()) else { continue; @@ -501,6 +697,11 @@ impl LanguageServer for BackendWrapper { .unwrap_or(0); project.handle_document_opened(change.uri, text, file_type, version); + changed_any = true; + } + + if changed_any { + self.0.refresh_workspace_diagnostics_if_supported().await; } } @@ -590,7 +791,6 @@ impl LanguageServer for BackendWrapper { 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 @@ -599,14 +799,12 @@ impl LanguageServer for BackendWrapper { generic_exit_statements("LSP", "diagnostic"); return Ok(empty_diagnostic_report()); }; - let doc_snapshot: Option<(FileType, String, Tree)> = { + let diagnostics = { let data = project.data.read(); - data.documents - .get(&uri) - .map(|d| (d.file_type.clone(), d.content.clone(), d.tree.clone())) + collect_document_diagnostics(&data, &uri) }; - let (file_type, content, tree) = match doc_snapshot { + let diagnostics = match diagnostics { Some(v) => v, None => { self.0 @@ -616,24 +814,7 @@ impl LanguageServer for BackendWrapper { 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, @@ -646,6 +827,36 @@ impl LanguageServer for BackendWrapper { ) } + async fn workspace_diagnostic( + &self, + _params: WorkspaceDiagnosticParams, + ) -> Result { + let projects: Vec<_> = self.0.projects.read().values().cloned().collect(); + let mut items = Vec::new(); + + for project in projects { + let data = project.data.read(); + for (uri, document) in &data.documents { + let Some(diagnostics) = collect_document_diagnostics(&data, uri) else { + continue; + }; + + items.push(WorkspaceDocumentDiagnosticReport::Full( + WorkspaceFullDocumentDiagnosticReport { + uri: uri.clone(), + version: document.version.map(i64::from), + full_document_diagnostic_report: FullDocumentDiagnosticReport { + result_id: None, + items: diagnostics, + }, + }, + )); + } + } + + Ok(WorkspaceDiagnosticReport { items }.into()) + } + async fn goto_definition( &self, params: GotoDefinitionParams, @@ -674,9 +885,7 @@ impl LanguageServer for BackendWrapper { return Ok(None); }; - let Some(class_name) = document.class_name.clone() else { - return Ok(None); - }; + let class_name = document.class_name.clone(); ( document.content.clone(), document.tree.clone(), @@ -726,7 +935,19 @@ impl LanguageServer for BackendWrapper { MemberType::MethodDef => { definitions = { let data = project.data.read(); - data.get_method_superclass(name_string, &class_id) + data.get_method_superclass(name_string, &class_id, &uri) + } + } + MemberType::PropertyDef => { + definitions = { + let data = project.data.read(); + data.get_property_superclass(name_string, &class_id, &uri) + } + } + MemberType::ParameterDef => { + definitions = { + let data = project.data.read(); + data.get_parameter_superclass(name_string, &class_id) } } MemberType::Class => { @@ -735,6 +956,60 @@ impl LanguageServer for BackendWrapper { data.get_class_definition(&name_string) } } + MemberType::RelativeParameter => { + definitions = { + let data = project.data.read(); + let parameter_ref = if let Some(p_ref) = data + .parameter_defs + .get(&class_name) + .and_then(|parameters| parameters.get(&name_string)) + { + p_ref + } else { + if let Some(p_ref) = data + .override_index + .effective_parameters + .get(&class_name) + .and_then(|parameters| parameters.get(&name_string)) + { + eprintln!( + "Error: Parameter is defined in override index but NOT in parameter_defs" + ); + p_ref + } else { + return Ok(None); + } + }; + data.get_parameter_definition(parameter_ref) + } + } + MemberType::RelativeProperty => { + definitions = { + let data = project.data.read(); + let property_ref = if let Some(p_ref) = data + .property_defs + .get(&class_name) + .and_then(|properties| properties.get(&name_string)) + { + p_ref + } else { + if let Some(p_ref) = data + .override_index + .effective_properties + .get(&class_name) + .and_then(|properties| properties.get(&name_string)) + { + eprintln!( + "Error: Property is defined in override index but NOT in property_defs" + ); + p_ref + } else { + return Ok(None); + } + }; + data.get_property_definition(property_ref) + } + } MemberType::RelativeMethodCall => { definitions = { let data = project.data.read(); @@ -745,7 +1020,19 @@ impl LanguageServer for BackendWrapper { { m_ref } else { - return Ok(None); + if let Some(m_ref) = data + .override_index + .effective_methods + .get(&class_name) + .and_then(|methods| methods.get(&name_string)) + { + eprintln!( + "Error: Method is defined in override index but NOT in method_defs" + ); + m_ref + } else { + return Ok(None); + } }; data.get_method_definition(method_ref, None) } @@ -818,6 +1105,11 @@ impl LanguageServer for BackendWrapper { content, class_name.clone(), ); + + eprintln!( + "ROUTINE NAME: {:?}, METHOD NAME: {:?}, OFFSET: {:?}", + &routine_name, &method_name, offset + ); let method_ref = if let Some(m_ref) = data .method_defs .get(&routine_name) @@ -825,6 +1117,7 @@ impl LanguageServer for BackendWrapper { { m_ref } else { + eprintln!("Error: method ref not found"); return Ok(None); }; data.get_method_definition(method_ref, offset) @@ -1301,7 +1594,14 @@ impl LanguageServer for BackendWrapper { .get(&class_name_str) .and_then(|methods| methods.get(&name_string)) { - data.get_method_overrides(method_ref) + let Some(method_url) = data + .global_semantic_model + .get_class_symbol(&method_ref.class) + .map(|symbol| symbol.url.clone()) + else { + return Ok(None); + }; + data.get_method_overrides(method_ref, &method_url) } else { return Ok(None); } @@ -1315,12 +1615,18 @@ impl LanguageServer for BackendWrapper { 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) + data.get_method_overrides(&method_ref, &uri) } else { return Ok(None); } }; } + MemberType::ClassDef => { + overrides = { + let data = project.data.read(); + data.get_class_implementations(&class_id) + } + } _ => return Ok(None), } } @@ -1398,6 +1704,7 @@ impl LanguageServer for BackendWrapper { file_type, params.text_document.version, ); + self.0.refresh_workspace_diagnostics_if_supported().await; } async fn did_change(&self, params: DidChangeTextDocumentParams) { @@ -1453,6 +1760,7 @@ impl LanguageServer for BackendWrapper { // 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); + self.0.refresh_workspace_diagnostics_if_supported().await; return; }; @@ -1561,9 +1869,11 @@ impl LanguageServer for BackendWrapper { doc.version = Some(new_version); doc.file_type = file_type.clone(); } + data.clear_diagnostics_for_url(&uri); } if file_type == FileType::Xml { + self.0.refresh_workspace_diagnostics_if_supported().await; return; } @@ -1572,8 +1882,19 @@ impl LanguageServer for BackendWrapper { .client .log_message(MessageType::ERROR, format!("New Tree has Errors")) .await; + self.0.refresh_workspace_diagnostics_if_supported().await; } else { - project.update_document(uri, new_tree, file_type, new_version, old_text.as_str()); + let changed_ranges: Vec = + new_tree.changed_ranges(&old_tree).collect(); + project.update_document( + uri, + &new_tree, + file_type, + new_version, + old_text.as_str(), + changed_ranges, + ); + self.0.refresh_workspace_diagnostics_if_supported().await; } } } @@ -1727,7 +2048,7 @@ if { assert_eq!(document.file_type, FileType::Xml); assert!(document.class_id.is_none()); - assert!(document.class_name.is_none()); + assert_eq!(&document.class_name, "XML"); } #[test] @@ -1872,6 +2193,158 @@ set = 1 ); } + #[tokio::test] + async fn workspace_indexing_keeps_clean_xml_diagnostics_empty() { + let (service, _socket) = LspService::build(|client| BackendWrapper::new(client)).finish(); + let backend = service.inner(); + + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("diagnostics"); + let workspace_uri = Url::from_file_path(&project_root).unwrap(); + let state = ProjectState::new(); + state + .project_root_path + .set(Some(project_root.clone())) + .expect("failed to set workspace root"); + backend.0.add_project(workspace_uri.clone(), state); + backend.0.index_workspace(&workspace_uri).await; + + let clean_xml_uri = Url::from_file_path(project_root.join("injected-clean.xml")).unwrap(); + let project = backend + .0 + .get_project(&workspace_uri) + .expect("missing project state"); + let data = project.data.read(); + let diagnostics = collect_document_diagnostics(&data, &clean_xml_uri) + .expect("indexed XML document should be tracked"); + + assert!( + diagnostics.is_empty(), + "clean XML should not inherit parse errors from workspace indexing: {diagnostics:#?}" + ); + } + + #[test] + fn collect_document_diagnostics_filters_semantic_diagnostics_when_strict_mode_is_disabled() { + let state = ProjectState::new(); + let uri = Url::parse("file:///tmp/example.mac").unwrap(); + let content = "ROUTINE example\n\nmain\n quit\n"; + state.handle_document_opened(uri.clone(), content.to_string(), FileType::Routine, 1); + + let mut data = state.data.write(); + data.other_class_diagnostics + .entry(uri.clone()) + .or_default() + .push(Diagnostic { + range: Range::new(Position::new(0, 0), Position::new(0, 1)), + severity: Some(DiagnosticSeverity::ERROR), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: "semantic diagnostic".to_string(), + related_information: None, + tags: None, + data: None, + }); + + data.config.enable_strict_mode = false; + let non_strict = collect_document_diagnostics(&data, &uri) + .expect("document should have diagnostics in non-strict mode"); + assert!( + non_strict.is_empty(), + "non-strict mode should filter semantic diagnostics from a clean document" + ); + + data.config.enable_strict_mode = true; + let strict = collect_document_diagnostics(&data, &uri) + .expect("document should have diagnostics in strict mode"); + assert_eq!(strict.len(), 1); + assert_eq!(strict[0].message, "semantic diagnostic"); + } + + #[test] + fn collect_document_diagnostics_returns_empty_when_lint_is_disabled() { + let state = ProjectState::new(); + let uri = Url::parse("file:///tmp/example.mac").unwrap(); + let content = "ROUTINE example\n\nmain\n set = 1\n"; + state.handle_document_opened(uri.clone(), content.to_string(), FileType::Routine, 1); + + let mut data = state.data.write(); + data.config.enable_lint = false; + + let diagnostics = collect_document_diagnostics(&data, &uri) + .expect("document should have diagnostics when lint is disabled"); + assert!(diagnostics.is_empty()); + } + + #[tokio::test] + async fn did_change_configuration_updates_project_config() { + let (service, _socket) = LspService::build(|client| BackendWrapper::new(client)).finish(); + let backend = service.inner(); + + let project_root = env::current_dir().unwrap(); + let workspace_uri = Url::from_file_path(&project_root).unwrap(); + let state = ProjectState::new(); + state + .project_root_path + .set(Some(project_root.clone())) + .expect("failed to set workspace root"); + backend.0.add_project(workspace_uri.clone(), state); + + backend + .did_change_configuration(DidChangeConfigurationParams { + settings: serde_json::json!({ + "objectscript": { + "enableStrictMode": false, + "enableLint": false + } + }), + }) + .await; + + let project = backend + .0 + .get_project(&workspace_uri) + .expect("missing project state"); + let data = project.data.read(); + assert!(!data.config.enable_strict_mode); + assert!(!data.config.enable_lint); + } + + #[tokio::test] + async fn did_change_configuration_ignores_empty_settings() { + let (service, _socket) = LspService::build(|client| BackendWrapper::new(client)).finish(); + let backend = service.inner(); + + let project_root = env::current_dir().unwrap(); + let workspace_uri = Url::from_file_path(&project_root).unwrap(); + let state = ProjectState::new(); + state + .project_root_path + .set(Some(project_root.clone())) + .expect("failed to set workspace root"); + state.data.write().config.enable_strict_mode = false; + backend.0.add_project(workspace_uri.clone(), state); + + backend + .did_change_configuration(DidChangeConfigurationParams { + settings: serde_json::json!({}), + }) + .await; + + let project = backend + .0 + .get_project(&workspace_uri) + .expect("missing project state"); + let data = project.data.read(); + assert!( + !data.config.enable_strict_mode, + "empty configuration notifications should not reset strict mode to the default" + ); + } + #[tokio::test] async fn collect_workspace_refactor_changes_finds_routine_edits() { let project_root = env::current_dir() diff --git a/objectscript-lsp/src/server.rs b/objectscript-lsp/src/server.rs index 3ed9b59..29798c9 100644 --- a/objectscript-lsp/src/server.rs +++ b/objectscript-lsp/src/server.rs @@ -1,15 +1,17 @@ -use objectscript_core::common::get_member_name_from_root; +use objectscript_core::common::{get_member_name_and_range_from_root, ts_range_to_lsp_range}; 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 std::sync::atomic::{AtomicBool, Ordering}; use tower_lsp::Client; -use tower_lsp::lsp_types::{MessageType, Url}; +use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, MessageType, Url}; use tree_sitter::Parser; use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; use tree_sitter_objectscript_routine::LANGUAGE_OBJECTSCRIPT_ROUTINE; +use tree_sitter_xml::LANGUAGE_XML; use walkdir::WalkDir; /// Arc-wrapped backend providing the LSP language server implementation. @@ -25,6 +27,8 @@ pub(crate) struct Backend { pub(crate) client: Client, /// Stores Url -> ProjectState for each Workspace. pub(crate) projects: Arc>>>, + pub(crate) diagnostic_refresh_supported: AtomicBool, + pub(crate) configuration_supported: AtomicBool, } impl Backend { @@ -33,6 +37,24 @@ impl Backend { Self { client, projects: Arc::new(RwLock::new(HashMap::new())), + diagnostic_refresh_supported: AtomicBool::new(false), + configuration_supported: AtomicBool::new(false), + } + } + + pub(crate) fn set_diagnostic_refresh_supported(&self, supported: bool) { + self.diagnostic_refresh_supported + .store(supported, Ordering::Relaxed); + } + + pub(crate) fn set_configuration_supported(&self, supported: bool) { + self.configuration_supported + .store(supported, Ordering::Relaxed); + } + + pub(crate) async fn refresh_workspace_diagnostics_if_supported(&self) { + if self.diagnostic_refresh_supported.load(Ordering::Relaxed) { + let _ = self.client.workspace_diagnostic_refresh().await; } } @@ -93,7 +115,7 @@ impl Backend { project.handle_document_opened(uri, text, file_type, version); } - /// Index all `.cls` and `.inc` files under the workspace root containing `uri`. + /// Index all supported ObjectScript and XML 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 @@ -133,6 +155,12 @@ impl Backend { return; } + let mut xml_parser = Parser::new(); + if xml_parser.set_language(&LANGUAGE_XML.into()).is_err() { + eprintln!("Error: Failed to load XML 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(); @@ -167,58 +195,95 @@ impl Backend { } }; - let tree = if is_rtn { - match routine_parser.parse(&content, None) { + let tree = match filetype { + FileType::Routine => 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) { + }, + FileType::Cls => match cls_parser.parse(&content, None) { Some(t) => t, None => { eprintln!("Failed to parse file for: {:?}", path.display()); continue; } - } + }, + FileType::Xml => match xml_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; - } + let (class_range, class_name, class_name_def_range) = if filetype == FileType::Xml { + ( + tree.root_node().range(), + "XML".to_string(), + tree.root_node().range(), + ) + } else { + if let Some((class_range, class_name, class_name_def_range)) = + get_member_name_and_range_from_root(&content, tree.root_node(), is_rtn) + { + (class_range, class_name, class_name_def_range) + } else { + 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 workspace_contains_class_name = data.classes.contains_key(&class_name); let already_exists = data.add_document_if_absent( url.clone(), - content, - tree, + content.clone(), + &tree, filetype, - member_name, + class_name.clone(), + class_range, None, ); if already_exists { documents_already_existing.push(url); + } else if !already_exists && workspace_contains_class_name { + let lsp_range = + ts_range_to_lsp_range(content.as_str(), class_name_def_range); + let diagnostic = Diagnostic { + range: lsp_range, + severity: Some(DiagnosticSeverity::ERROR), + code: None, + code_description: None, + source: Some("ObjectScript".to_string()), + message: format!( + "A Class named {:?} already exists in this workspace.", + &class_name + ), + related_information: None, + tags: None, + data: None, + }; + data.other_class_diagnostics + .entry(url.clone()) + .or_insert(Vec::new()) + .push(diagnostic); } } } - { - let mut data = project.data.write(); - data.build_inheritance_and_variables(None, documents_already_existing); - } + eprintln!("INFO: Finished indexing workspace"); }); // Wait for completion (and handle join errors) if let Err(join_err) = handle.await { eprintln!("Error: index_workspace_scope spawn_blocking failed: {join_err:?}"); } + self.refresh_workspace_diagnostics_if_supported().await; } } diff --git a/objectscript-lsp/src/test.rs b/objectscript-lsp/src/test.rs index 64ef695..39d4c3d 100644 --- a/objectscript-lsp/src/test.rs +++ b/objectscript-lsp/src/test.rs @@ -1,23 +1,85 @@ #[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 objectscript_core::common::{ + advance_point, get_keyword_and_value, parse_line_ref, point_to_byte, position_to_point, + }; + use objectscript_core::parse_structures::{ + FileType, Language, MethodRef, RefactorLevel, VariableRef, + }; + use objectscript_core::refactor::refactor_conditionals_in_document; + use objectscript_core::workspace::{ProjectData, ProjectState}; use std::collections::HashSet; use std::env; use std::path::PathBuf; - use tower_lsp::lsp_types::Url; - use tree_sitter::{Parser, Point, Range}; + use std::sync::{Mutex, OnceLock}; + use tower_lsp::lsp_types::{Position, Url}; + use tree_sitter::{InputEdit, Parser, Point, Range}; + use tree_sitter_objectscript::LANGUAGE_OBJECTSCRIPT_UDL; + use tree_sitter_objectscript_playground::LANGUAGE_OBJECTSCRIPT; use tree_sitter_objectscript_routine::LANGUAGE_OBJECTSCRIPT_ROUTINE; - fn parse_routine(code: &str) -> tree_sitter::Tree { + fn new_parser(language: tree_sitter::Language, name: &str) -> Mutex { 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") + .set_language(&language) + .unwrap_or_else(|_| panic!("failed to load {name} grammar")); + Mutex::new(parser) + } + + fn parser_for_file_type(file_type: FileType) -> &'static Mutex { + static ROUTINE_PARSER: OnceLock> = OnceLock::new(); + static CLS_PARSER: OnceLock> = OnceLock::new(); + static OBJECTSCRIPT_PARSER: OnceLock> = OnceLock::new(); + + match file_type { + FileType::Routine => ROUTINE_PARSER + .get_or_init(|| new_parser(LANGUAGE_OBJECTSCRIPT_ROUTINE.into(), "routine")), + FileType::Cls => { + CLS_PARSER.get_or_init(|| new_parser(LANGUAGE_OBJECTSCRIPT_UDL.into(), "UDL")) + } + FileType::Xml => OBJECTSCRIPT_PARSER + .get_or_init(|| new_parser(LANGUAGE_OBJECTSCRIPT.into(), "ObjectScript")), + } + } + + fn with_refactor_parser(file_type: FileType, f: impl FnOnce(&mut Parser) -> T) -> T { + let mut parser = parser_for_file_type(file_type) + .lock() + .expect("test parser mutex poisoned"); + f(&mut parser) + } + + fn parse_for_file_type( + code: &str, + file_type: FileType, + parser: &mut Parser, + ) -> tree_sitter::Tree { + parser + .parse(code, None) + .unwrap_or_else(|| panic!("parse returned None for {file_type:?}")) + } + + fn parse_routine(code: &str) -> tree_sitter::Tree { + with_refactor_parser(FileType::Routine, |parser| { + parse_for_file_type(code, FileType::Routine, parser) + }) + } + + fn refactor_conditionals_for_test(input: &str, file_type: FileType) -> String { + with_refactor_parser(file_type, |parser| { + let tree = parse_for_file_type(input, file_type, parser); + let (updated, _) = refactor_conditionals_in_document(input, file_type, tree, parser); + updated + }) + } + + fn method_owner_url(project_data: &ProjectData, method_ref: &MethodRef) -> Url { + project_data + .global_semantic_model + .get_class_symbol(&method_ref.class) + .map(|symbol| symbol.url.clone()) + .expect("method owner class symbol should exist") } async fn setup_backend_and_workspace(project_root: PathBuf) -> (BackendTester, Url) { @@ -144,6 +206,63 @@ mod tests { eprintln!("VARIABLES: {:#?}", project_data.pub_var_defs.clone()) } + #[tokio::test] + async fn test_scope_tree_for_routine() { + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("gotodef") + .join("routines"); + 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 methods = project_data.method_defs.get("crossref").unwrap(); + + println!("METHOD LEN {:?}", methods.len()); + } + + #[tokio::test] + async fn test_goto_def_inherited_method_relative() { + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("gotodef") + .join("relative-method-call"); + 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 superclass_id = project_data.classes.get("hk").unwrap(); + let superclass = project_data + .global_semantic_model + .get_class(superclass_id) + .unwrap(); + let superclass_method_ref = superclass.get_method_ref("print2").unwrap(); + let methods = project_data + .override_index + .effective_methods + .get("hksubclass") + .unwrap(); + eprintln!("METHODS {:?}", methods); + let method_ref = methods.get("print2").unwrap(); + assert_eq!(method_ref, superclass_method_ref); + + let unresolved_key = ("hksubclass".to_string(), "print2".to_string()); + assert!( + !project_data + .unresolved_method_references + .contains_key(&unresolved_key), + "inherited relative method call should not remain unresolved" + ); + assert!( + !project_data + .method_reference_diagnostics + .contains_key(&unresolved_key), + "inherited relative method call should not keep a stale diagnostic" + ); + } + #[tokio::test] async fn test_variables() { let project_root = env::current_dir() @@ -214,7 +333,14 @@ mod tests { assert_eq!(before_y.len(), 3); assert_eq!(superclass_count, 2); - project_state.update_document(document_url, tree, FileType::Cls, 1, content.as_str()); + project_state.update_document( + document_url, + &tree, + FileType::Cls, + 1, + content.as_str(), + vec![], + ); let ( after_public_variables, @@ -290,12 +416,20 @@ mod tests { }; assert_eq!( - sub_one_class_inherited.inherited_classes, - vec![super_class_id] + sub_one_class_inherited + .inherited_classes + .iter() + .map(|(class_name, _)| class_name.clone()) + .collect::>(), + vec!["SuperClass".to_string()] ); assert_eq!( - sub_two_class_inherited.inherited_classes, - vec![super_class_id] + sub_two_class_inherited + .inherited_classes + .iter() + .map(|(class_name, _)| class_name.clone()) + .collect::>(), + vec!["SuperClass".to_string()] ); } @@ -312,16 +446,19 @@ mod tests { 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 { + for class_id in classes.values() { + let Some(class) = gsm.classes.get(class_id) else { panic!("Class DNE"); }; + // eprintln!("CLASS: {:#?}", class); + assert_eq!(class.is_procedure_block, Some(false)); assert_eq!(class.default_language, Some(Language::Objectscript)); - assert_eq!(class.inheritance_direction, "right"); + assert_eq!(class.inheritance_direction, Some("right".to_string())); // get methods for (_, method_ref) in class.methods.clone() { let method = gsm.methods.get(&method_ref).unwrap(); + eprintln!("METHOD: {:#?}", method); if method.name == "newVarChange" { assert_eq!(method.variables.len(), 1); let variable_refs = method.variables.get("x").unwrap(); @@ -331,7 +468,7 @@ mod tests { assert!(variable_ref.priv_id.is_some()); } assert_eq!(method.is_procedure_block, Some(true)); - assert_eq!(method.language, Some(Language::Objectscript)); + assert_eq!(method.language, None); } else { let all_var_refs: Vec<&Vec<(VariableRef, _)>> = method.variables.values().collect(); @@ -341,8 +478,8 @@ mod tests { assert!(variable_ref.priv_id.is_none()); } } - assert_eq!(method.is_procedure_block, Some(false)); - assert_eq!(method.language, Some(Language::Objectscript)); + assert_eq!(method.is_procedure_block, None); + assert_eq!(method.language, None); } } } @@ -459,8 +596,7 @@ mod tests { 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 + let refactored = project_state .refactor(RefactorLevel::DoCommands) .into_iter() .find_map(|(content, url)| (url == test_mac_url).then_some(content)) @@ -484,8 +620,7 @@ mod tests { 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 + let refactored = project_state .refactor(RefactorLevel::DoCommands) .into_iter() .find_map(|(content, url)| (url == test_mac_url).then_some(content)) @@ -509,8 +644,7 @@ mod tests { 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 + let refactored = project_state .refactor(RefactorLevel::DoCommands) .into_iter() .find_map(|(content, url)| (url == test_mac_url).then_some(content)) @@ -535,8 +669,7 @@ mod tests { 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 + let refactored = project_state .refactor(RefactorLevel::DoCommands) .into_iter() .find_map(|(content, url)| (url == test_mac_url).then_some(content)) @@ -602,7 +735,12 @@ after quit "#; - let actual = refactor_legacy_do_statements(input); + let state = ProjectState::new(); + let uri = Url::parse("file:///tmp/refactors-old-dotted-do.mac").unwrap(); + state.handle_document_opened(uri.clone(), input.to_string(), FileType::Routine, 1); + let actual = state + .refactor_document(&uri, RefactorLevel::DoCommands) + .expect("expected legacy do refactor to change content"); assert_eq!(actual, expected); } @@ -624,7 +762,7 @@ check() quit "#; - let actual = refactor_conditionals(input, FileType::Routine); + let actual = refactor_conditionals_for_test(input, FileType::Routine); assert_eq!(actual, expected); } @@ -642,13 +780,140 @@ check() i x = 2 { w hi i y = 5 { - w goodbye + w goodbye + } + } + quit +"#; + + let actual = refactor_conditionals_for_test(input, FileType::Routine); + assert_eq!(actual, expected); + } + + #[test] + fn refactors_old_if_in_cls_method_into_braced_block() { + let input = r#"Class Bench.Refactor +{ +Method Test() +{ + i x = 2 set y = 5 set z = 6 + quit +} +} +"#; + let expected = r#"Class Bench.Refactor +{ +Method Test() +{ + i x = 2 { + set y = 5 + set z = 6 + } + quit +} +} +"#; + + let actual = refactor_conditionals_for_test(input, FileType::Cls); + assert_eq!(actual, expected); + } + + #[test] + fn refactors_old_if_in_indented_cls_method_to_next_indent_level() { + let input = r#"Class Bench.Refactor +{ +Method Test() +{ + i x = 2 set y = 5 set z = 6 + quit +} +} +"#; + let expected = r#"Class Bench.Refactor +{ +Method Test() +{ + i x = 2 { + set y = 5 + set z = 6 + } + quit +} +} +"#; + + let actual = refactor_conditionals_for_test(input, FileType::Cls); + assert_eq!(actual, expected); } + + #[test] + fn refactors_old_if_else_in_cls_method_into_braced_blocks() { + let input = r#"Class Bench.Refactor +{ +Method Test() +{ + i x = 2 set y = 5 + e set z = 6 + quit +} +} +"#; + let expected = r#"Class Bench.Refactor +{ +Method Test() +{ + i x = 2 { + set y = 5 + } else { + set z = 6 + } + quit +} +} +"#; + + let actual = refactor_conditionals_for_test(input, FileType::Cls); + assert_eq!(actual, expected); + } + + #[test] + fn refactors_standalone_old_else_in_indented_cls_method_without_duplicate_indent() { + let input = r#"Class LegacyIfCode +{ + Method validElse() + { + else w "yes this is valid" + } +} +"#; + let expected = r#"Class LegacyIfCode +{ + Method validElse() + { + if $TEST = 0 { + w "yes this is valid" + } + } +} +"#; + + let actual = refactor_conditionals_for_test(input, FileType::Cls); + assert_eq!(actual, expected); + } + + #[test] + fn refactors_old_if_in_xml_objectscript_content_with_playground_grammar() { + let input = r#" i x = 2 set y = 5 set z = 6 + quit +"#; + let expected = r#" i x = 2 { + set y = 5 + set z = 6 } quit "#; - let actual = refactor_conditionals(input, FileType::Routine); + let actual = refactor_conditionals_for_test(input, FileType::Xml); assert_eq!(actual, expected); } @@ -668,7 +933,8 @@ check() .get("SuperClass") .and_then(|m| m.get("newVarChange")) .unwrap(); - let locations = project_data.get_method_overrides(method_ref); + let method_url = method_owner_url(&project_data, method_ref); + let locations = project_data.get_method_overrides(method_ref, &method_url); assert_eq!(locations.len(), 2); let paths: HashSet = locations .into_iter() @@ -694,7 +960,8 @@ check() .get("Demo.NavSuper") .and_then(|m| m.get("Overridden")) .unwrap(); - let locations = project_data.get_method_overrides(method_ref); + let method_url = method_owner_url(&project_data, method_ref); + let locations = project_data.get_method_overrides(method_ref, &method_url); assert_eq!(locations.len(), 2); let paths: HashSet = locations @@ -765,10 +1032,11 @@ check() drop(data); project_state.update_document( sub_public_url.clone(), - tree, + &tree, file_type, version, sub_public_content.as_str(), + vec![], ); } @@ -787,8 +1055,11 @@ check() .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); + let locations_public = project_data.get_method_superclass( + "Overridden".to_string(), + &sub_public_class_id, + &sub_public_url, + ); assert!( !locations_public.is_empty(), "sub_public's Overridden should resolve to super's Overridden (after late indexing)" @@ -796,8 +1067,11 @@ check() 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); + let locations_private = project_data.get_method_superclass( + "Overridden".to_string(), + &sub_private_class_id, + &sub_private_url, + ); assert!( !locations_private.is_empty(), "sub_private's Overridden should resolve to super's Overridden" @@ -867,7 +1141,6 @@ check() 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 result = project_data .method_defs .get("Demo.Utility") @@ -914,7 +1187,7 @@ check() let content = document.content.as_str(); let oref_range = range_for_substring(content, "do obj.Run()"); - let class_name = document.class_name.as_deref().unwrap(); + let class_name = &document.class_name; let locations = project_data.get_oref_definitions("obj", "Run", class_name, oref_range, true); @@ -944,7 +1217,7 @@ check() let content = document.content.as_str(); let oref_range = range_for_substring(content, "job worker.Execute()"); - let class_name = document.class_name.as_deref().unwrap(); + let class_name = &document.class_name; let locations = project_data.get_oref_definitions("worker", "Execute", class_name, oref_range, true); @@ -975,13 +1248,27 @@ check() .get("Demo.ChildDefault") .expect("Demo.ChildDefault should exist"); - let locations = project_data.get_method_superclass("UseParent".to_string(), child_class_id); + let child_url = Url::from_file_path(project_root.join("child-left.cls")).unwrap(); + let locations = + project_data.get_method_superclass("UseParent".to_string(), child_class_id, &child_url); // UseParent is not in any parent, so no superclass resolution assert!( locations.is_empty(), "UseParent is unique to ChildDefault, no superclass def" ); + let common_ref = project_data + .method_defs + .get("Demo.ChildDefault") + .and_then(|methods| methods.get("Common")) + .expect("Demo.ChildDefault should see inherited Common"); + let common_url = method_owner_url(&project_data, common_ref); + assert!( + common_url.path().ends_with("base.cls"), + "default left inheritance should resolve Common through LeftParent to Demo.Base, got {}", + common_url.path() + ); + // Verify inheritance is recorded (left parent first) let class = project_data .global_semantic_model @@ -993,6 +1280,59 @@ check() ); } + #[tokio::test] + async fn test_goto_def_multiple_inheritance_late_left_parent_prefers_base() { + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("gotodef") + .join("multiple-inheritance"); + + let state = ProjectState::new(); + state.project_root_path.set(Some(project_root.clone())).ok(); + 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"); + + for file_name in [ + "base.cls", + "right-parent.cls", + "child-left.cls", + "left-parent.cls", + ] { + let url = Url::from_file_path(project_root.join(file_name)).unwrap(); + let content = std::fs::read_to_string(project_root.join(file_name)).unwrap(); + project_state.handle_document_opened(url, content, FileType::Cls, 1); + } + + let project_data = project_state.data.read(); + let common_ref = project_data + .override_index + .effective_methods + .get("Demo.ChildDefault") + .and_then(|methods| methods.get("Common")) + .expect("Demo.ChildDefault should have effective Common"); + let common_url = method_owner_url(&project_data, common_ref); + assert!( + common_url.path().ends_with("base.cls"), + "late LeftParent indexing should move Common from RightParent to Demo.Base, got {}", + common_url.path() + ); + + let method_defs_ref = project_data + .method_defs + .get("Demo.ChildDefault") + .and_then(|methods| methods.get("Common")) + .expect("goto-def should see inherited Common through method_defs"); + let method_defs_url = method_owner_url(&project_data, method_defs_ref); + assert!( + method_defs_url.path().ends_with("base.cls"), + "goto-def should resolve Common through method_defs to Demo.Base, got {}", + method_defs_url.path() + ); + } + #[tokio::test] async fn test_goto_def_multiple_inheritance_right_direction() { let project_root = env::current_dir() @@ -1013,11 +1353,23 @@ check() .global_semantic_model .get_class(child_right_id) .expect("class should exist"); - assert_eq!(class.inheritance_direction, "right"); + assert_eq!(class.inheritance_direction, Some("right".to_string())); assert!( !class.inherited_classes.is_empty(), "should have inherited classes" ); + + let common_ref = project_data + .method_defs + .get("Demo.ChildRight") + .and_then(|methods| methods.get("Common")) + .expect("Demo.ChildRight should see inherited Common"); + let common_url = method_owner_url(&project_data, common_ref); + assert!( + common_url.path().ends_with("right-parent.cls"), + "right inheritance should resolve Common through RightParent, got {}", + common_url.path() + ); } // ========================================================================= @@ -1040,7 +1392,8 @@ check() .and_then(|m| m.get("DeepMethod")) .expect("Demo.DeepSuper.DeepMethod should exist"); - let locations = project_data.get_method_overrides(method_ref); + let method_url = method_owner_url(&project_data, method_ref); + let locations = project_data.get_method_overrides(method_ref, &method_url); // DeepMid overrides DeepSuper, and DeepLeafOne/Two override DeepMid // Direct overrides of DeepSuper.DeepMethod is DeepMid assert!( @@ -1066,14 +1419,15 @@ check() 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(); - + eprintln!("PROJECT DATA OVERRIDES {:#?}", project_data.override_index); let method_ref = project_data .method_defs .get("Demo.DeepMid") .and_then(|m| m.get("DeepMethod")) .expect("Demo.DeepMid.DeepMethod should exist"); - let locations = project_data.get_method_overrides(method_ref); + let method_url = method_owner_url(&project_data, method_ref); + let locations = project_data.get_method_overrides(method_ref, &method_url); assert!( !locations.is_empty(), "DeepMid.DeepMethod should have overrides in leaf classes" @@ -1102,7 +1456,8 @@ check() .and_then(|m| m.get("Unique")) .expect("Demo.NoOverrides.Unique should exist"); - let locations = project_data.get_method_overrides(method_ref); + let method_url = method_owner_url(&project_data, method_ref); + let locations = project_data.get_method_overrides(method_ref, &method_url); assert!( locations.is_empty(), "method with no subclasses should have no overrides" @@ -1227,6 +1582,116 @@ check() assert!(errors.is_empty(), "clean .mac should have no parse errors"); } + #[test] + fn test_diagnostics_postconditional_do_dotted_block_has_no_errors() { + let content = r#"ROUTINE test + +dottedComment + do + . /*w hi*/ + . set x = 1 + . if x { + . w "bye" if z d + . w "in z" + . } +"#; + + let tree = parse_routine(content); + let errors = objectscript_core::common::collect_error_nodes(tree.root_node()); + let details = errors + .iter() + .map(|node| { + format!( + "{} missing={} [{}:{} - {}:{}]", + node.kind(), + node.is_missing(), + node.start_position().row + 1, + node.start_position().column + 1, + node.end_position().row + 1, + node.end_position().column + 1 + ) + }) + .collect::>() + .join(", "); + assert!( + errors.is_empty(), + "postconditional DO dotted block should have no parse errors: {details}" + ); + } + + #[test] + fn test_incremental_edit_postconditional_do_dotted_block_matches_full_parse() { + let old_content = std::fs::read_to_string( + env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("dotted-block") + .join("test-dotted-block.mac"), + ) + .unwrap(); + let old_text = ". w \"bye\""; + let new_text = ". w \"bye\" if z d\n . w \"in z\""; + let new_content = old_content.replace(old_text, new_text); + + with_refactor_parser(FileType::Routine, |parser| { + let mut incremental_text = old_content.clone(); + let mut incremental_tree = + parse_for_file_type(incremental_text.as_str(), FileType::Routine, parser); + let full_tree = parse_for_file_type(new_content.as_str(), FileType::Routine, parser); + + let start_byte = old_content + .find(old_text) + .expect("old text should exist in fixture"); + let old_end_byte = start_byte + old_text.len(); + let start_point = point_from_byte_index(old_content.as_str(), start_byte); + let old_end_point = point_from_byte_index(old_content.as_str(), old_end_byte); + let start_position = Position { + line: start_point.row as u32, + character: start_point.column as u32, + }; + let old_end_position = Position { + line: old_end_point.row as u32, + character: old_end_point.column as u32, + }; + + let start_position = position_to_point(incremental_text.as_str(), start_position); + let start_byte = point_to_byte(incremental_text.as_str(), start_position); + let old_end_position = position_to_point(incremental_text.as_str(), old_end_position); + let old_end_byte = point_to_byte(incremental_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, + }; + incremental_text.replace_range(start_byte..old_end_byte, new_text); + incremental_tree.edit(&input_edit); + let incremental_tree = parser + .parse(incremental_text.as_str(), Some(&incremental_tree)) + .expect("incremental parse should succeed"); + + let full_errors = objectscript_core::common::collect_error_nodes(full_tree.root_node()); + let incremental_errors = + objectscript_core::common::collect_error_nodes(incremental_tree.root_node()); + assert_eq!( + full_errors.len(), + incremental_errors.len(), + "incremental parse should report the same number of errors as a full parse" + ); + assert_eq!( + full_tree.root_node().to_sexp(), + incremental_tree.root_node().to_sexp(), + "incremental parse should match a full parse" + ); + }); + } + #[test] fn test_diagnostics_multiple_errors_routine() { let content = std::fs::read_to_string( @@ -1377,7 +1842,8 @@ check() .expect("Demo.OrderChild should be indexed"); // goto def on Greet in child should resolve to parent's Greet - let locations = project_data.get_method_superclass("Greet".to_string(), child_class_id); + let locations = + project_data.get_method_superclass("Greet".to_string(), child_class_id, &child_url); assert!( !locations.is_empty(), "child opened before parent: goto def should still resolve after parent loads" @@ -1417,7 +1883,8 @@ check() .get("Demo.OrderChild") .expect("Demo.OrderChild should be indexed"); - let locations = project_data.get_method_superclass("Greet".to_string(), child_class_id); + let locations = + project_data.get_method_superclass("Greet".to_string(), child_class_id, &child_url); assert!( !locations.is_empty(), "parent opened before child: goto def should resolve" @@ -1482,6 +1949,149 @@ check() assert_eq!(doc.version, Some(2), "version should be updated to latest"); } + #[tokio::test] + async fn test_routine_gotodef_method_offset() { + let project_root = env::current_dir() + .unwrap() + .join("objectscript-tests") + .join("gotodef") + .join("routines"); + 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 document_url = Url::from_file_path(project_root.join("offset-goto.mac")).unwrap(); + let document = project_data + .documents + .get(&document_url) + .expect("offset-goto.mac should be indexed"); + let point = point_for_substring(&document.content, "main+3"); + let node = document + .tree + .root_node() + .named_descendant_for_point_range(point, point) + .expect("main+3 should resolve to a syntax node"); + let mut ancestor = Some(node); + let mut line_ref = None; + while let Some(current) = ancestor { + if current.kind() == "line_ref" { + line_ref = Some(current); + break; + } + ancestor = current.parent(); + } + let line_ref = line_ref.expect("main+3 should parse as a line_ref"); + let (routine_name, method_name, offset) = + parse_line_ref(line_ref, &document.content, "offsetgoto".to_string()); + assert_eq!(routine_name, "offsetgoto"); + assert_eq!(method_name, "main"); + assert_eq!(offset, Some(3)); + + let class_id = project_data + .classes + .get(&routine_name) + .expect("offsetgoto routine should be indexed as a class"); + let class = project_data + .global_semantic_model + .get_class(class_id) + .expect("offsetgoto semantic class should exist"); + let method_ref = class + .methods + .get(&method_name) + .expect("main tag should be indexed as a routine method"); + + let locations = project_data.get_method_definition(method_ref, offset); + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].0, document_url); + assert_eq!( + locations[0].1.start_point.row, 5, + "main+3 should resolve to the row three lines after the main tag" + ); + assert_eq!( + document + .content + .lines() + .nth(locations[0].1.start_point.row) + .unwrap(), + " w !, \"line after main\"" + ); + + let correct_key = ("offsetgoto".to_string(), "main".to_string()); + let swapped_key = ("main".to_string(), "offsetgoto".to_string()); + assert!( + !project_data + .unresolved_method_references + .contains_key(&correct_key), + "main+3 should resolve to the main tag in the current routine" + ); + assert!( + !project_data + .method_reference_diagnostics + .contains_key(&correct_key), + "main+3 should not create an unresolved-method diagnostic" + ); + assert!( + !project_data + .unresolved_method_references + .contains_key(&swapped_key), + "line_ref parsing should not swap routine and tag names" + ); + assert!( + !project_data + .method_reference_diagnostics + .contains_key(&swapped_key), + "line_ref parsing should not leave a stale swapped-key diagnostic" + ); + } + + #[tokio::test] + async fn test_routine_variable_definition_keeps_distinct_call_paths() { + let project_root = env::current_dir().unwrap().join("routines"); + 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 tagcalls_url = Url::from_file_path(project_root.join("tag-calls.mac")).unwrap(); + let tagcalls_doc = project_data + .documents + .get(&tagcalls_url) + .expect("tag-calls.mac should be indexed"); + let x_use_point = point_for_substring(&tagcalls_doc.content, "x\n w !, \"helping\""); + + let locations = + project_data.get_variable_definition(&tagcalls_url, x_use_point, "x".to_string()); + let mut resolved_lines = HashSet::new(); + for (url, range) in &locations { + let file_name = PathBuf::from(url.path()) + .file_name() + .unwrap() + .to_string_lossy() + .to_string(); + let document = project_data + .documents + .get(url) + .expect("definition document should be indexed"); + let line = document + .content + .lines() + .nth(range.start_point.row) + .unwrap() + .trim() + .to_string(); + resolved_lines.insert((file_name, line)); + } + + assert_eq!( + locations.len(), + 2, + "x in tagcalls.helper should resolve to the nearest definition on each call path" + ); + assert!(resolved_lines.contains(&("tag-calls.mac".to_string(), "set x = 1".to_string()))); + assert!( + resolved_lines.contains(&("offset-goto.mac".to_string(), "set x = 72".to_string())) + ); + } + // ========================================================================= // GOTO DEFINITION — EDGE CASES (Test Suite 1.8) // ========================================================================= @@ -1548,7 +2158,7 @@ check() .expect("document should be tracked"); assert_eq!(doc.file_type, FileType::Cls); assert!(doc.class_id.is_some(), "class_id should be set for .cls"); - assert_eq!(doc.class_name.as_deref(), Some("Demo.Hello")); + assert_eq!(&doc.class_name, "Demo.Hello"); } #[test] @@ -1568,7 +2178,7 @@ check() doc.class_id.is_some(), "class_id should be set for routines" ); - assert_eq!(doc.class_name.as_deref(), Some("mytest")); + assert_eq!(&doc.class_name, "mytest"); } #[test] @@ -1595,7 +2205,7 @@ check() assert_eq!(doc.file_type, FileType::Xml); assert!(doc.class_id.is_none(), "XML docs should not have class_id"); assert!( - doc.class_name.is_none(), + &doc.class_name == "XML", "XML docs should not have class_name" ); } @@ -1623,10 +2233,11 @@ check() project_state.update_document( parent_url.clone(), - tree, + &tree, file_type, version + 1, content.as_str(), + vec![], ); // Verify the document is still consistent @@ -1648,8 +2259,7 @@ check() let content = "ROUTINE modern\n\nmain\n set x = 1\n quit\n"; state.handle_document_opened(uri.clone(), content.to_string(), FileType::Routine, 1); - let data = state.data.read(); - let result = data.refactor_document(&uri, RefactorLevel::DoCommands); + let result = state.refactor_document(&uri, RefactorLevel::DoCommands); assert!( result.is_none(), "document with no legacy syntax should return None" @@ -1664,9 +2274,7 @@ check() .join("diagnostics"); 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 changes = project_data.refactor(RefactorLevel::All); + let changes = project_state.refactor(RefactorLevel::All); for (_, url) in &changes { assert!( !url.path().ends_with(".xml"), diff --git a/src/objectscript.rs b/src/objectscript.rs index d7f08e4..2928536 100644 --- a/src/objectscript.rs +++ b/src/objectscript.rs @@ -1,11 +1,6 @@ use std::fs; -use zed_extension_api::{ - self as zed, - settings::LspSettings, - LanguageServerId, - Result, -}; +use zed_extension_api::{self as zed, settings::LspSettings, LanguageServerId, Result}; struct ObjectScriptBinary { path: String, @@ -55,7 +50,7 @@ impl ObjectScriptExtension { ); let release = zed::latest_github_release( - "hkimura-intersys/zed-objectscript", + "intersystems/zed-objectscript", zed::GithubReleaseOptions { require_assets: true, pre_release: false, @@ -111,7 +106,7 @@ impl ObjectScriptExtension { zed::Os::Windows => zed::DownloadedFileType::Zip, }, ) - .map_err(|e| format!("failed to download file: {e}"))?; + .map_err(|e| format!("failed to download file: {e}"))?; let entries = fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?;