Generate code, refactor existing code, explain code, and get answers to questions about software development.
-
+
The most capable generative AI-powered assistant for building, operating, and transforming software, with advanced capabilities for managing data and AI
+
+
Agent capabilities
+
Implement new features
+
/dev to task Amazon Q with generating new code across your entire project and implement features.
+
+
Generate documentation
+
/docs to task Amazon Q with writing API, technical design, and onboarding documentation.
+
+
Automate code reviews
+
/review to ask Amazon Q to perform code reviews, flagging suspicious code patterns and assessing deployment risk.
+
+
Generate unit tests
+
/test to ask Amazon Q to generate unit tests and add them to your project, helping you improve code quality, fast.
-
Security scans
-
Analyze and fix security vulnerabilities in your project.
/transform to upgrade your Java applications in minutes, not weeks.
-
Agent for software development
-
Let Amazon Q plan and implement new functionality across multiple files in your workspace. Type “/” in chat to open the quick actions menu and choose the “/dev” action.
+
Core features
-
Agent for code transformation
-
Upgrade your Java applications in minutes, not weeks. Type “/” in chat to open the quick actions menu and choose the “/transform” action.
Seamlessly initial chat within the inline coding experience. Select a section of code that you need assistance with and initiate chat within the editor to request actions such as "Optimize this code", "Add comments", or "Write tests".
+
+
Chat
+
Generate code, explain code, and get answers about software development.
Attribute code from Amazon Q that is similar to training data. When code suggestions similar to training data are accepted, they will be added to the code reference log.
-
Getting Started
-
Free Tier - create or log in with an AWS Builder ID (no AWS account needed!).
- Pro Tier - if your organization is on the Amazon Q Developer Pro tier, log in with single sign-on.
-
+
Getting Started
+
Free Tier - create or log in with an AWS Builder ID (a personal profile from AWS).
+
Pro Tier - if your organization is on the Amazon Q Developer Pro tier, log in with single sign-on.
]]>
1.0
diff --git a/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-critical.svg b/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-critical.svg
new file mode 100644
index 00000000000..7733994d24e
--- /dev/null
+++ b/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-critical.svg
@@ -0,0 +1,4 @@
+
diff --git a/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-high.svg b/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-high.svg
new file mode 100644
index 00000000000..ff92aebc817
--- /dev/null
+++ b/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-high.svg
@@ -0,0 +1,4 @@
+
diff --git a/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-info.svg b/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-info.svg
new file mode 100644
index 00000000000..dbf78609170
--- /dev/null
+++ b/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-info.svg
@@ -0,0 +1,4 @@
+
diff --git a/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-low.svg b/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-low.svg
new file mode 100644
index 00000000000..4ca6d96961e
--- /dev/null
+++ b/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-low.svg
@@ -0,0 +1,4 @@
+
diff --git a/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-medium.svg b/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-medium.svg
new file mode 100644
index 00000000000..a906d9b4873
--- /dev/null
+++ b/plugins/core/jetbrains-community/resources/icons/resources/codewhisperer/severity-initial-medium.svg
@@ -0,0 +1,4 @@
+
diff --git a/plugins/core/jetbrains-community/resources/telemetryOverride.json b/plugins/core/jetbrains-community/resources/telemetryOverride.json
index d0fb37e5ce6..5637baaa16e 100644
--- a/plugins/core/jetbrains-community/resources/telemetryOverride.json
+++ b/plugins/core/jetbrains-community/resources/telemetryOverride.json
@@ -1,5 +1,20 @@
{
"types": [
+ {
+ "name": "acceptedCharactersCount",
+ "type": "int",
+ "description": "The number of accepted characters"
+ },
+ {
+ "name": "acceptedCount",
+ "type": "int",
+ "description": "The number of accepted cases"
+ },
+ {
+ "name": "acceptedLinesCount",
+ "type": "int",
+ "description": "The number of accepted lines of code"
+ },
{
"name": "amazonqIndexFileSizeInMB",
"type": "int",
@@ -25,6 +40,16 @@
"type": "string",
"description": "Source triggering token refresh"
},
+ {
+ "name": "buildPayloadBytes",
+ "type": "int",
+ "description": "The uncompressed payload size in bytes of the source files in customer project context"
+ },
+ {
+ "name": "buildZipFileBytes",
+ "type": "int",
+ "description": "The compressed payload size of source files in bytes of customer project context sent"
+ },
{
"name": "component",
"allowedValues": [
@@ -160,6 +185,51 @@
],
"description": "Identifies the specific interaction that opens the chat panel"
},
+ {
+ "name": "executedCount",
+ "type": "int",
+ "description": "The number of executed operations"
+ },
+ {
+ "name": "generatedCharactersCount",
+ "type": "int",
+ "description": "Number of characters of code generated"
+ },
+ {
+ "name": "generatedCount",
+ "type": "int",
+ "description": "The number of generated cases"
+ },
+ {
+ "name": "generatedLinesCount",
+ "type": "int",
+ "description": "The number of generated lines of code"
+ },
+ {
+ "name": "hasUserPromptSupplied",
+ "type": "boolean",
+ "description": "True if user supplied prompt message as input else false"
+ },
+ {
+ "name": "isCodeBlockSelected",
+ "type": "boolean",
+ "description": "True if user selected code snippet as input else false"
+ },
+ {
+ "name": "isSupportedLanguage",
+ "type": "boolean",
+ "description": "Indicate if the language is supported"
+ },
+ {
+ "name": "jobGroup",
+ "type": "string",
+ "description": "Job group name used in the operation"
+ },
+ {
+ "name": "jobId",
+ "type": "string",
+ "description": "Job id used in the operation"
+ },
{
"name": "reAuth",
"type": "boolean",
@@ -449,6 +519,92 @@
}
]
},
+ {
+ "name": "amazonq_utgGenerateTests",
+ "description": "Client side invocation of the AmazonQ Unit Test Generation",
+ "metadata": [
+ {
+ "type": "acceptedCharactersCount",
+ "required": false
+ },
+ {
+ "type": "acceptedCount",
+ "required": false
+ },
+ {
+ "type": "acceptedLinesCount",
+ "required": false
+ },
+ {
+ "type": "artifactsUploadDuration",
+ "required": false
+ },
+ {
+ "type": "buildPayloadBytes",
+ "required": false
+ },
+ {
+ "type": "buildZipFileBytes",
+ "required": false
+ },
+ {
+ "type": "credentialStartUrl",
+ "required": false
+ },
+ {
+ "type": "cwsprChatProgrammingLanguage"
+ },
+ {
+ "type": "generatedCharactersCount",
+ "required": false
+ },
+ {
+ "type": "generatedCount",
+ "required": false
+ },
+ {
+ "type": "generatedLinesCount",
+ "required": false
+ },
+ {
+ "type": "hasUserPromptSupplied"
+ },
+ {
+ "type": "isCodeBlockSelected",
+ "required": false
+ },
+ {
+ "type": "isSupportedLanguage"
+ },
+ {
+ "type": "jobGroup",
+ "required": false
+ },
+ {
+ "type": "jobId",
+ "required": false
+ },
+ {
+ "type": "perfClientLatency",
+ "required": false
+ },
+ {
+ "type": "result"
+ },
+ {
+ "type": "reason",
+ "required": false
+ },
+ {
+ "type": "reasonDesc",
+ "required": false
+ },
+ {
+ "type": "source",
+ "required": false
+ }
+ ]
+ },
{
"name": "auth_modifyConnection",
"description": "An auth connection was modified in some way, e.g. deleted, updated",
diff --git a/plugins/core/jetbrains-community/src/icons/AwsIcons.kt b/plugins/core/jetbrains-community/src/icons/AwsIcons.kt
index 024c57f80ce..3ed96be9f50 100644
--- a/plugins/core/jetbrains-community/src/icons/AwsIcons.kt
+++ b/plugins/core/jetbrains-community/src/icons/AwsIcons.kt
@@ -115,6 +115,8 @@ object AwsIcons {
object CodeWhisperer {
@JvmField val CUSTOM = load("icons/resources/CodewhispererCustom.svg") // 16 * 16
+ // Icons with full severity string
+
@JvmField val SEVERITY_INFO = load("/icons/resources/codewhisperer/severity-info.svg")
@JvmField val SEVERITY_LOW = load("/icons/resources/codewhisperer/severity-low.svg")
@@ -124,6 +126,18 @@ object AwsIcons {
@JvmField val SEVERITY_HIGH = load("/icons/resources/codewhisperer/severity-high.svg")
@JvmField val SEVERITY_CRITICAL = load("/icons/resources/codewhisperer/severity-critical.svg")
+
+ // Icons with severity initials
+
+ @JvmField val SEVERITY_INITIAL_INFO = load("/icons/resources/codewhisperer/severity-initial-info.svg")
+
+ @JvmField val SEVERITY_INITIAL_LOW = load("/icons/resources/codewhisperer/severity-initial-low.svg")
+
+ @JvmField val SEVERITY_INITIAL_MEDIUM = load("/icons/resources/codewhisperer/severity-initial-medium.svg")
+
+ @JvmField val SEVERITY_INITIAL_HIGH = load("/icons/resources/codewhisperer/severity-initial-high.svg")
+
+ @JvmField val SEVERITY_INITIAL_CRITICAL = load("/icons/resources/codewhisperer/severity-initial-critical.svg")
}
}
diff --git a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties
index f64ba2c2693..c0750e73ad6 100644
--- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties
+++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties
@@ -17,29 +17,60 @@ action.aws.toolkit.dynamoViewer.changeMaxResults.text=Max Results
action.aws.toolkit.dynamodb.delete_table.text=Delete Table...
action.aws.toolkit.ecr.repository.pull.text=Pull from Repository...
action.aws.toolkit.ecr.repository.push.text=Push to Repository...
-action.aws.toolkit.jetbrains.core.services.cwc.commands.ExplainCodeAction.description = Explains the selected code
-action.aws.toolkit.jetbrains.core.services.cwc.commands.ExplainCodeAction.text = Explain Code
-action.aws.toolkit.jetbrains.core.services.cwc.commands.FixCodeAction.description = Fixes the selected code
-action.aws.toolkit.jetbrains.core.services.cwc.commands.FixCodeAction.text = Fix Code
-action.aws.toolkit.jetbrains.core.services.cwc.commands.GenerateUnitTestsAction.description = Generates unit tests for the selected code
-action.aws.toolkit.jetbrains.core.services.cwc.commands.GenerateUnitTestsAction.text = Generate Tests (Beta)
-action.aws.toolkit.jetbrains.core.services.cwc.commands.OptimizeCodeAction.description = Optimizes the selected code
-action.aws.toolkit.jetbrains.core.services.cwc.commands.OptimizeCodeAction.text = Optimize Code
-action.aws.toolkit.jetbrains.core.services.cwc.commands.RefactorCodeAction.description = Refactors the selected code
-action.aws.toolkit.jetbrains.core.services.cwc.commands.RefactorCodeAction.text = Refactor Code
-action.aws.toolkit.jetbrains.core.services.cwc.commands.SendToPromptAction.description = Sends selected code to chat
-action.aws.toolkit.jetbrains.core.services.cwc.commands.SendToPromptAction.text = Send to Prompt
-action.aws.toolkit.jetbrains.core.services.cwc.inline.openChat.text = Inline Chat
+action.aws.toolkit.jetbrains.core.services.cwc.commands.ExplainCodeAction.description=Explains the selected code
+action.aws.toolkit.jetbrains.core.services.cwc.commands.ExplainCodeAction.text=Explain Code
+action.aws.toolkit.jetbrains.core.services.cwc.commands.FixCodeAction.description=Fixes the selected code
+action.aws.toolkit.jetbrains.core.services.cwc.commands.FixCodeAction.text=Fix Code
+action.aws.toolkit.jetbrains.core.services.cwc.commands.GenerateUnitTestsAction.description=Generates unit tests for the selected code
+action.aws.toolkit.jetbrains.core.services.cwc.commands.GenerateUnitTestsAction.text=Generate Tests
+action.aws.toolkit.jetbrains.core.services.cwc.commands.OptimizeCodeAction.description=Optimizes the selected code
+action.aws.toolkit.jetbrains.core.services.cwc.commands.OptimizeCodeAction.text=Optimize Code
+action.aws.toolkit.jetbrains.core.services.cwc.commands.RefactorCodeAction.description=Refactors the selected code
+action.aws.toolkit.jetbrains.core.services.cwc.commands.RefactorCodeAction.text=Refactor Code
+action.aws.toolkit.jetbrains.core.services.cwc.commands.SendToPromptAction.description=Sends selected code to chat
+action.aws.toolkit.jetbrains.core.services.cwc.commands.SendToPromptAction.text=Send to Prompt
+action.aws.toolkit.jetbrains.core.services.cwc.inline.openChat.text=Inline Chat
action.aws.toolkit.open.arn.browser.text=Open ARN in AWS Console
action.aws.toolkit.open.telemetry.viewer.text=View AWS Telemetry
action.aws.toolkit.s3.open.bucket.viewer.prefixed.text=View Bucket with Prefix...
action.aws.toolkit.s3.open.bucket.viewer.text=View Bucket
action.aws.toolkit.toolwindow.explorer.newConnection.text=Setup authentication to begin
action.aws.toolkit.toolwindow.newConnection.text=Add Another Connection...
-action.aws.toolkit.toolwindow.sso.signout.text=Sign out of SSO
action.dynamic.open.text=Open Resource...
action.q.openchat.text=Open Chat Panel
amazonqChat.project_context.index_in_progress=By the way, I'm still indexing this project for full context from your workspace. I may have a better response in a few minutes when it's complete if you'd like to try again then.
+amazonqDoc.edit.message=Please describe the changes you would like to make to your documentation. For example, you can ask me to add a section, remove a section, make a correction, expand upon something, etc.
+amazonqDoc.edit.placeholder=Describe your documentation request
+amazonqDoc.error.generating=Unable to generate changes.
+amazonqDoc.error_text=I'm sorry, I ran into an issue while trying to generate your documentation. Please try again.
+amazonqDoc.exception.content_length_error=Your workspace is too large for me to review. Your workspace must be within the quota, even if you choose a smaller folder. For more information on quotas, see the Amazon Q Developer documentation.
+amazonqDoc.exception.no_change_required=I couldn't find any code changes to update in the README. Try another documentation task.
+amazonqDoc.exception.prompt_too_vague=I need more information to make changes to your README. Try providing some of the following details:\n- Which sections you want to modify\n- The content you want to add or remove\n- Specific issues that need correcting\n\nFor more information on prompt best practices, see the Amazon Q Developer documentation.
+amazonqDoc.exception.prompt_unrelated=These changes don't seem related to documentation. Try describing your changes again, using the following best practices:\n- Changes should relate to how project functionality is reflected in the README\n- Content you refer to should be available in your codebase\n\nFor more information on prompt best practices, see the Amazon Q Developer documentation.
+amazonqDoc.exception.readme_too_large=The README in your folder is too large for me to review. Try reducing the size of your README, or choose a folder with a smaller README. For more information on quotas, see the Amazon Q Developer documentation.
+amazonqDoc.exception.workspace_empty=The folder you chose did not contain any source files in a supported language. Choose another folder and try again. For more information on supported languages, see the Amazon Q Developer documentation.
+amazonqDoc.inprogress_message.generating=Generating documentation...
+amazonqDoc.progress_message.baseline=This may take a few minutes.
+amazonqDoc.progress_message.creating=Okay, I'm creating a README for your project.
+amazonqDoc.progress_message.generating=Generating documentation
+amazonqDoc.progress_message.scanning=Scanning source files
+amazonqDoc.progress_message.summarizing=Summarizing source files
+amazonqDoc.progress_message.updating=Okay, I'm updating the README to reflect your code changes.
+amazonqDoc.prompt.create=Create a README
+amazonqDoc.prompt.folder.change=Change folder
+amazonqDoc.prompt.folder.proceed=Yes
+amazonqDoc.prompt.placeholder=Choose an option to continue
+amazonqDoc.prompt.reject.close_session=End session
+amazonqDoc.prompt.reject.message=Your changes have been discarded.
+amazonqDoc.prompt.reject.new_task=Start a new documentation task
+amazonqDoc.prompt.review.accept=Accept
+amazonqDoc.prompt.review.changes=Make changes
+amazonqDoc.prompt.review.message=Please review and accept the changes.
+amazonqDoc.prompt.update=Update an existing README
+amazonqDoc.prompt.update.follow_up.edit=Make a specific edit or change
+amazonqDoc.prompt.update.follow_up.sync=Synchronize with recent code changes
+amazonqDoc.session.create=Create documentation for a specific folder
+amazonqDoc.session.sync=Sync documentation
amazonqFeatureDev.chat_message.ask_for_new_task=What new task would you like to work on?
amazonqFeatureDev.chat_message.closed_session=Okay, I've ended this chat session. You can open a new tab to chat or start another workflow.
amazonqFeatureDev.chat_message.requesting_changes=Requesting changes ...
@@ -223,6 +254,9 @@ aws.settings.auto_update.text=Automatically install plugin updates when availabl
aws.settings.aws_cli_settings=AWS CLI Settings
aws.settings.codewhisperer.automatic_import_adder=Imports recommendation
aws.settings.codewhisperer.automatic_import_adder.tooltip=Amazon Q will add import statements with code suggestions when necessary
+aws.settings.codewhisperer.code_review=Code Review
+aws.settings.codewhisperer.code_review.description=Specifies a list of code issue identifiers(separated by ";") that Amazon Q should ignore when reviewing your workspace. Each item in the array should be a unique string identifier for a specific code issue. This allows you to suppress notifications for known issues that you've assessed and determined to be false positives or not applicable to your project. Use this setting with caution, as it may cause you to miss important security alerts.
+aws.settings.codewhisperer.code_review.title=Ignored Security Issues
aws.settings.codewhisperer.configurable.controlled_by_admin=\ Controlled by your admin
aws.settings.codewhisperer.configurable.opt_out.title=Share Amazon Q content with AWS
aws.settings.codewhisperer.configurable.opt_out.tooltip=When checked, your content processed by Amazon Q may be used for service improvement (except for content processed by the Amazon Q Developer Pro tier). Unchecking this box will cause AWS to delete any of your content used for that purpose. The information used to provide the Amazon Q service to you will not be affected. See the Service Terms for more detail.
@@ -791,54 +825,86 @@ codemodernizer.toolwindow.table.header.run_length=Job running time
codemodernizer.toolwindow.table.header.status=Status
codemodernizer.toolwindow.transformation.progress.header=Transformation progress
codemodernizer.toolwindow.transformation.progress.running_time=Running time: {0}
+codescan.chat.message.button.fileScan=Review active file
+codescan.chat.message.button.openIssues=View in Code Issues Panel
+codescan.chat.message.button.projectScan=Review project
+codescan.chat.message.error_request=Request failed
+codescan.chat.message.not_git_repo=Your workspace is not in a git repository. I'll review your project files for security issues, and your in-flight changes for code quality issues.
+codescan.chat.message.project_scan_failed=Sorry, I ran into an issue during the review. Please try again.
+codescan.chat.message.scan_begin_file=Okay, I'm reviewing your file for code issues.
+codescan.chat.message.scan_begin_project=Okay, I'm reviewing your project for code issues.
+codescan.chat.message.scan_begin_wait_time=This may take a few minutes. I'll share updates here as I work on this.
+codescan.chat.message.scan_file_in_progress=File review is in progress...
+codescan.chat.message.scan_project_in_progress=Project review is in progress...
+codescan.chat.message.scan_step_1=Initiating code review.
+codescan.chat.message.scan_step_2=Waiting for review to finish.
+codescan.chat.message.scan_step_3=Processing review results.
+codescan.chat.new_scan.input.message=Which type of review would you like to run?
+codescan.chat.placeholder.scan_in_progress=Reviewing code issues...
+codescan.chat.placeholder.waiting_for_inputs=Waiting on your inputs...
codewhisperer.actions.connect_github.title=Connect with Us on GitHub
codewhisperer.actions.open_settings.title=Open Settings
codewhisperer.actions.send_feedback.title=Send Feedback
codewhisperer.actions.view_documentation.title=View Documentation
+codewhisperer.codefix.code_fix_job_timed_out=Amazon Q: Timed out generating code fix
+codewhisperer.codefix.create_code_fix_error=Amazon Q: Failed to generate fix for the issue
+codewhisperer.codefix.invalid_zip_error=Amazon Q: Failed to create valid zip
codewhisperer.codescan.apply_fix_button_label=Apply fix
codewhisperer.codescan.apply_fix_button_tooltip=Apply suggested fix
codewhisperer.codescan.build_artifacts_not_found=Cannot find build artifacts for the project. Try rebuilding the Java project in IDE or specify compilation output path in File | Project Structure... | Project | Compiler output:
-codewhisperer.codescan.cancelled_by_user_exception=Code scan job cancelled by user.
+codewhisperer.codescan.cancelled_by_user_exception=Code review job cancelled by user.
codewhisperer.codescan.cannot_read_file=Amazon Q encountered an error while parsing a file.
+codewhisperer.codescan.clear_filters=Clear Filters
codewhisperer.codescan.cwe_label=Common Weakness Enumeration (CWE)
codewhisperer.codescan.detector_library_label=Detector library
-codewhisperer.codescan.explain_button_label=Amazon Q: Explain
-codewhisperer.codescan.file_ext_not_supported=File extension {0} is not supported for Amazon Q Security Scan feature. Please try again with a valid file format - java, python, javascript, typescript, csharp, yaml, json, tf, hcl, ruby, go.
+codewhisperer.codescan.explain_button_label=Explain
+codewhisperer.codescan.file_ext_not_supported=File extension {0} is not supported for the Amazon Q Code Review feature. Please try again with a valid file format - java, python, javascript, typescript, csharp, yaml, json, tf, hcl, ruby, go.
codewhisperer.codescan.file_name_issues_count= {0} {1} {2, choice, 1#1 issue|2#{2,number} issues}
codewhisperer.codescan.file_not_found=For file path {0} with error message: {0}
-codewhisperer.codescan.file_too_large=Amazon Q: The selected file exceeds the input artifact limit. Try again with a smaller file. For more information about scan limits, see the Amazon Q documentation.
+codewhisperer.codescan.file_path_label=File Path
+codewhisperer.codescan.file_too_large=Amazon Q: The selected file exceeds the input artifact limit. Try again with a smaller file. For more information about review limits, see the Amazon Q documentation.
codewhisperer.codescan.file_too_large_telemetry=Payload size limit reached
codewhisperer.codescan.fix_applied_fail=Apply fix command failed. {0}
codewhisperer.codescan.fix_available_label=Code fix available
codewhisperer.codescan.fix_button_label=Fix with Q
+codewhisperer.codescan.generate_fix_button_label=Generate Fix
+codewhisperer.codescan.ignore_all_button=Ignore All
+codewhisperer.codescan.ignore_button=Ignore
codewhisperer.codescan.invalid_source_zip_telemetry=Failed to create valid source zip.
-codewhisperer.codescan.java_module_not_found=Java plugin is required for scanning Java files, install Java plugin or perform the code scan in Intellij Idea instead.
-codewhisperer.codescan.no_file_open=Amazon Q: No file is open in an active editor. Open a file to start a Security Scan.
-codewhisperer.codescan.no_file_open_telemetry=Open a valid file to scan.
-codewhisperer.codescan.problems_window_not_found=Unable to display Security Scan results as the Problems View tool window cannot be fetched.
-codewhisperer.codescan.run_scan=Run Project Scan
-codewhisperer.codescan.run_scan_complete= Security Scan completed for {0, choice, 1#1 file|2#{0,number} files}. {1, choice, 0#No issues|1#1 issue|2#{1,number} issues} found in {2}. Last Run {4}
-codewhisperer.codescan.run_scan_error=Amazon Q encountered an error while scanning for security issues. Please try again later.
-codewhisperer.codescan.run_scan_error_telemetry=Security scan failed.
-codewhisperer.codescan.run_scan_info=Select 'Run' in toolbar to scan this package for security issues.
-codewhisperer.codescan.scan_display=Amazon Q Security Issues
-codewhisperer.codescan.scan_display_with_issues=Amazon Q Security Issues {0}
-codewhisperer.codescan.scan_in_progress=Scanning active project and its dependencies...
+codewhisperer.codescan.java_module_not_found=Java plugin is required for reviewing Java files, install Java plugin or perform the code review in Intellij Idea instead.
+codewhisperer.codescan.no_file_open=Amazon Q: No file is open in an active editor. Open a file to start a Code Review.
+codewhisperer.codescan.no_file_open_telemetry=Open a valid file to review.
+codewhisperer.codescan.problems_window_not_found=Unable to display Code Review results as the Problems View tool window cannot be fetched.
+codewhisperer.codescan.quota_exceeded=You've reached the monthly quota for Amazon Q Developer's agent capabilities. You can try again next month. For more information on usage limits, see the Amazon Q Developer pricing page.
+codewhisperer.codescan.regenerate_fix_button_label=Regenerate Fix
+codewhisperer.codescan.run_scan=Full Project Scan is now /review! Open in Chat Panel
+codewhisperer.codescan.run_scan_complete= Code Review completed for {0, choice, 1#1 file|2#{0,number} files}. {1, choice, 0#No issues|1#1 issue|2#{1,number} issues} found in {2}. Last Run {4}
+codewhisperer.codescan.run_scan_error=Amazon Q encountered an error while reviewing for code issues. Please try again later.
+codewhisperer.codescan.run_scan_error_telemetry=Code Review failed.
+codewhisperer.codescan.run_scan_info=Enter /review in Amazon Q Chat Panel to run code reviews.
+codewhisperer.codescan.scan_complete_count=- {0}: `{1, choice, 1#{1,number} issue|2#{1,number} issues}`
+codewhisperer.codescan.scan_complete_file=Reviewing your File is complete. Here's what I found:
+codewhisperer.codescan.scan_complete_project=Reviewing your Project is complete. Here's what I found:
+codewhisperer.codescan.scan_display=Amazon Q Code Issues
+codewhisperer.codescan.scan_display_with_issues=Amazon Q Code Issues {0}
+codewhisperer.codescan.scan_in_progress=Code review in progress...
codewhisperer.codescan.scan_recommendation= {0} {1}
-codewhisperer.codescan.scan_recommendation_invalid= {0} {1} [No longer valid: Re-scan to validate the fix]
-codewhisperer.codescan.scan_recommendation_invalid.tooltip_text=No longer valid. Re-scan to validate the fix.
-codewhisperer.codescan.scan_timed_out=Security Scan failed. Amazon Q timed out.
-codewhisperer.codescan.scanned_files_heading= {0} files were scanned during the last security scan.
-codewhisperer.codescan.stop_scan=Stop Security Scan
-codewhisperer.codescan.stop_scan_confirm_button=Stop scan
-codewhisperer.codescan.stop_scan_confirm_message=Are you sure you want to stop ongoing security scan? This scan will be counted as one complete scan towards your monthly security scan limits.
-codewhisperer.codescan.stopping_scan=Stopping Security Scan...
+codewhisperer.codescan.scan_recommendation_invalid= {0} {1} [No longer valid: Re-run the review to validate the fix]
+codewhisperer.codescan.scan_recommendation_invalid.tooltip_text=No longer valid. Re-run the review to validate the fix.
+codewhisperer.codescan.scan_results_hidden_by_filters=All code review results are hidden by current filters.
+codewhisperer.codescan.scan_timed_out=Code Review failed. Amazon Q timed out.
+codewhisperer.codescan.scanned_files_heading= {0} files were reviewed during the last code review.
+codewhisperer.codescan.severity_issues_count= {0} {1, choice, 1#{1,number} issue|2#{1,number} issues}
+codewhisperer.codescan.stop_scan=Stop Code Review
+codewhisperer.codescan.stop_scan_confirm_button=Stop review
+codewhisperer.codescan.stop_scan_confirm_message=Are you sure you want to stop ongoing code review? This review will be counted as one complete review towards your monthly code review limits.
+codewhisperer.codescan.stopping_scan=Stopping Code Review...
codewhisperer.codescan.suggested_fix_description=Why are we recommending this?
codewhisperer.codescan.suggested_fix_label=Suggested code fix preview
-codewhisperer.codescan.unsupported_language_error=Amazon Q: Project does not contain valid files to scan
-codewhisperer.codescan.unsupported_language_error_telemetry=Project does not contain valid files to scan
-codewhisperer.codescan.upload_to_s3_failed=Amazon Q is unable to upload your workspace artifacts to Amazon S3 for security scans. For more information, see the Amazon Q documentation.
-codewhisperer.codescan.view_scanned_files=View {0} scanned files
+codewhisperer.codescan.unsupported_language_error=Amazon Q: Project does not contain valid files to review
+codewhisperer.codescan.unsupported_language_error_telemetry=Project does not contain valid files to review
+codewhisperer.codescan.upload_to_s3_failed=Amazon Q is unable to upload your project artifacts to Amazon S3 for code reviews. For more information, see the Amazon Q documentation.
+codewhisperer.codescan.view_scanned_files=View {0} reviewed files
codewhisperer.credential.login.dialog.exception.cancel_login=Login cancelled
codewhisperer.credential.login.dialog.ok_button=Connect
codewhisperer.credential.login.dialog.prompt=Select a connection option to start using Amazon Q
@@ -866,11 +932,11 @@ codewhisperer.explorer.learn=Learn
codewhisperer.explorer.node.dismiss=Dismiss
codewhisperer.explorer.node.install_q=Install the Amazon Q Plugin
codewhisperer.explorer.pause_auto=Pause Auto-Suggestions
-codewhisperer.explorer.pause_auto_scans =Pause Auto-Scans
+codewhisperer.explorer.pause_auto_scans=Pause Auto-Reviews
codewhisperer.explorer.paused=\ Paused
codewhisperer.explorer.reconnect=Reconnect
codewhisperer.explorer.resume_auto=Resume Auto-Suggestions
-codewhisperer.explorer.resume_auto_scans =Resume Auto-Scans
+codewhisperer.explorer.resume_auto_scans=Resume Auto-Reviews
codewhisperer.explorer.tooltip.comment=Start with auto-suggestions and find more features here!
codewhisperer.explorer.tooltip.title=Get started with Amazon Q
codewhisperer.explorer.usage_limit_hit=\ Free tier limit met, paused until {0}
@@ -908,7 +974,7 @@ codewhisperer.notification.custom.simple.button.select_another_customization=Sel
codewhisperer.notification.custom.simple.button.select_customization=Select customization
codewhisperer.notification.remote.ide_unsupported.message=Please update your IDE backend to a 2023.3 or later version to continue using Amazon Q inline suggestions.
codewhisperer.notification.remote.ide_unsupported.title=Amazon Q inline suggestion not supported in this IDE version
-codewhisperer.notification.usage_limit.codescan.warn.content=Amazon Q: You have reached the monthly limit for project scans.
+codewhisperer.notification.usage_limit.codescan.warn.content=Amazon Q: You have reached the monthly limit for project reviews.
codewhisperer.notification.usage_limit.codesuggestion.warn.content=You have reached the monthly fair use limit of code recommendations.
codewhisperer.popup.button.accept=
Insert Code \u21E5
codewhisperer.popup.button.next=
Next →
@@ -924,10 +990,10 @@ codewhisperer.statusbar.popup.title=Reconnect to Amazon Q?
codewhisperer.statusbar.sub_menu.connect_help.title=Connect / Help
codewhisperer.statusbar.sub_menu.inline.title=Inline Suggestions
codewhisperer.statusbar.sub_menu.other_features.title=Other Features
-codewhisperer.statusbar.sub_menu.security_scans.title=Security Scans
+codewhisperer.statusbar.sub_menu.security_scans.title=Code Reviews
codewhisperer.statusbar.tooltip=Amazon Q status
codewhisperer.toolwindow.entry.prefix=[{0}] ACCEPTED recommendation with the following code provided with reference under
-codewhisperer.toolwindow.entry.suffix= {1, choice, 0#|1#. Added to {0}} at line {2}
+codewhisperer.toolwindow.entry.suffix={1, choice, 0#|1#. Added to {0}} at line {2}
codewhisperer.toolwindow.popup.text=Reference code under the {0} license from repository {1}
codewhisperer.toolwindow.settings=Amazon Q Settings
codewhisperer.toolwindow.settings.prefix=Don't want suggestions that include code with references? Uncheck this option in
@@ -1261,6 +1327,7 @@ general.acknowledge=Acknowledge
general.add.another=Add another
general.auth.reauthenticate=Reauthenticate
general.cancel=Cancel
+general.canceling=Canceling
general.close_button=Close
general.configure_button=Configure
general.confirm=Confirm
@@ -1304,6 +1371,7 @@ general.save=Save
general.select_button=Select
general.step.canceled={0} has been canceled
general.step.failed={0} has failed: {1}
+general.success=Complete...
general.time=Time
general.time.five_minutes=Five Minutes
general.time.one_minute=One Minute
@@ -1975,6 +2043,18 @@ sqs.subscribe.sns.validation.empty_topic=Topic must be specified.
sqs.toolwindow=SQS
sqs.url.parse_error=Error parsing SQS queue URL
tags.title=Tags
+testgen.error.generic_error_message=I am experiencing technical difficulties at the moment. Please try again in a few minutes.
+testgen.error.maximum_generations_reach=You've reached the monthly quota for Amazon Q Developer's agent capabilities. You can try again next month. For more information on usage limits, see the Amazon Q Developer pricing page.
+testgen.message.cancelled=Unit test generation cancelled.
+testgen.message.failed=Sorry, Test generation failed. Please try again in few minutes.
+testgen.message.regenerate_input=Sure thing. Please provide new instructions for me to generate the tests, and select the function(s) you would like to test.
+testgen.message.success=Unit test generation completed.
+testgen.no_file_found=Sorry, I couldn't find a file to generate tests.
+testgen.placeholder.newtab=Ask any coding question or type \u0022/\u0022 for actions
+testgen.placeholder.select_an_option = Please select an action to proceed (Accept or Reject)
+testgen.placeholder.view_diff="Select View Diff to see the generated unit tests"
+testgen.placeholder.waiting_on_your_inputs=Waiting on your inputs...
+testgen.progressbar.generate_unit_tests=Generating unit tests...
toolkit.login.aws_builder_id.already_connected.cancel=Use existing AWS Builder ID
toolkit.login.aws_builder_id.already_connected.message=You already signed in with an AWS Builder ID.\nSign out to add another?
toolkit.login.aws_builder_id.already_connected.reconnect=Sign out
diff --git a/plugins/core/sdk-codegen/codegen-resources/codewhispererruntime/service-2.json b/plugins/core/sdk-codegen/codegen-resources/codewhispererruntime/service-2.json
index 00b4bb23348..00e2305eb2d 100644
--- a/plugins/core/sdk-codegen/codegen-resources/codewhispererruntime/service-2.json
+++ b/plugins/core/sdk-codegen/codegen-resources/codewhispererruntime/service-2.json
@@ -1267,7 +1267,8 @@
"required": ["conversationId", "codeGenerationId"],
"members": {
"conversationId": { "shape": "ConversationId" },
- "codeGenerationId": { "shape": "CodeGenerationId" }
+ "codeGenerationId": { "shape": "CodeGenerationId" },
+ "intent": { "shape": "String" }
}
},
"GetTaskAssistCodeGenerationResponse": {
diff --git a/plugins/core/sdk-codegen/codegen-resources/codewhispererstreaming/service-2.json b/plugins/core/sdk-codegen/codegen-resources/codewhispererstreaming/service-2.json
index 39344997397..1e7898a48d7 100644
--- a/plugins/core/sdk-codegen/codegen-resources/codewhispererstreaming/service-2.json
+++ b/plugins/core/sdk-codegen/codegen-resources/codewhispererstreaming/service-2.json
@@ -288,7 +288,8 @@
"ExportContext":{
"type":"structure",
"members":{
- "transformationExportContext":{"shape":"TransformationExportContext"}
+ "transformationExportContext":{"shape":"TransformationExportContext"},
+ "unitTestGenerationExportContext":{"shape":"UnitTestGenerationExportContext"}
},
"union":true
},
@@ -296,7 +297,8 @@
"type":"string",
"enum":[
"TRANSFORMATION",
- "TASK_ASSIST"
+ "TASK_ASSIST",
+ "UNIT_TESTS"
]
},
"ExportResultArchiveRequest":{
@@ -625,6 +627,12 @@
"USAGE"
]
},
+ "TestGenerationJobGroupName":{
+ "type":"string",
+ "max":128,
+ "min":1,
+ "pattern":"[a-zA-Z0-9-_]+"
+ },
"TextDocument":{
"type":"structure",
"required":["relativeFilePath"],
@@ -706,6 +714,19 @@
"downloadArtifactType":{"shape":"TransformationDownloadArtifactType"}
}
},
+ "UUID":{
+ "type":"string",
+ "max":36,
+ "min":36
+ },
+ "UnitTestGenerationExportContext":{
+ "type":"structure",
+ "required":["testGenerationJobGroupName"],
+ "members":{
+ "testGenerationJobGroupName":{"shape":"TestGenerationJobGroupName"},
+ "testGenerationJobId":{"shape":"UUID"}
+ }
+ },
"UploadId":{
"type":"string",
"max":128,
From 2b4656e91747985b12e336032ee3c2085c773201 Mon Sep 17 00:00:00 2001
From: aws-toolkit-automation <>
Date: Tue, 3 Dec 2024 14:21:14 +0000
Subject: [PATCH 002/117] Updating version to 3.43
---
.changes/3.43.json | 17 +++++++++++++++++
...re-44636712-ede6-4e2a-b4d6-ca2b98da003d.json | 4 ----
...re-5c2fae3e-c794-438b-8af5-2c31c00ab000.json | 4 ----
...re-8fc6f4c9-6976-4a4a-88f0-bd4e6081a4b9.json | 4 ----
...re-aaff6061-f89e-402b-9bc7-e6ca5e477de5.json | 4 ----
CHANGELOG.md | 6 ++++++
gradle.properties | 2 +-
7 files changed, 24 insertions(+), 17 deletions(-)
create mode 100644 .changes/3.43.json
delete mode 100644 .changes/next-release/feature-44636712-ede6-4e2a-b4d6-ca2b98da003d.json
delete mode 100644 .changes/next-release/feature-5c2fae3e-c794-438b-8af5-2c31c00ab000.json
delete mode 100644 .changes/next-release/feature-8fc6f4c9-6976-4a4a-88f0-bd4e6081a4b9.json
delete mode 100644 .changes/next-release/feature-aaff6061-f89e-402b-9bc7-e6ca5e477de5.json
diff --git a/.changes/3.43.json b/.changes/3.43.json
new file mode 100644
index 00000000000..3ff7f67e04c
--- /dev/null
+++ b/.changes/3.43.json
@@ -0,0 +1,17 @@
+{
+ "date" : "2024-12-03",
+ "version" : "3.43",
+ "entries" : [ {
+ "type" : "feature",
+ "description" : "`/review` in Q chat to scan your code for vulnerabilities and quality issues, and generate fixes"
+ }, {
+ "type" : "feature",
+ "description" : "`/test` in Q chat to generate unit tests for java and python"
+ }, {
+ "type" : "feature",
+ "description" : "`/doc` in Q chat to generate and update documentation for your project"
+ }, {
+ "type" : "feature",
+ "description" : "Added system notifications to inform users about critical plugin updates and potential issues with available workarounds"
+ } ]
+}
\ No newline at end of file
diff --git a/.changes/next-release/feature-44636712-ede6-4e2a-b4d6-ca2b98da003d.json b/.changes/next-release/feature-44636712-ede6-4e2a-b4d6-ca2b98da003d.json
deleted file mode 100644
index 587728529e7..00000000000
--- a/.changes/next-release/feature-44636712-ede6-4e2a-b4d6-ca2b98da003d.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "type" : "feature",
- "description" : "`/review` in Q chat to scan your code for vulnerabilities and quality issues, and generate fixes"
-}
diff --git a/.changes/next-release/feature-5c2fae3e-c794-438b-8af5-2c31c00ab000.json b/.changes/next-release/feature-5c2fae3e-c794-438b-8af5-2c31c00ab000.json
deleted file mode 100644
index 695a9529162..00000000000
--- a/.changes/next-release/feature-5c2fae3e-c794-438b-8af5-2c31c00ab000.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "type" : "feature",
- "description" : "`/test` in Q chat to generate unit tests for java and python"
-}
diff --git a/.changes/next-release/feature-8fc6f4c9-6976-4a4a-88f0-bd4e6081a4b9.json b/.changes/next-release/feature-8fc6f4c9-6976-4a4a-88f0-bd4e6081a4b9.json
deleted file mode 100644
index e40d4e44167..00000000000
--- a/.changes/next-release/feature-8fc6f4c9-6976-4a4a-88f0-bd4e6081a4b9.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "type" : "feature",
- "description" : "`/doc` in Q chat to generate and update documentation for your project"
-}
diff --git a/.changes/next-release/feature-aaff6061-f89e-402b-9bc7-e6ca5e477de5.json b/.changes/next-release/feature-aaff6061-f89e-402b-9bc7-e6ca5e477de5.json
deleted file mode 100644
index 095758b2635..00000000000
--- a/.changes/next-release/feature-aaff6061-f89e-402b-9bc7-e6ca5e477de5.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "type" : "feature",
- "description" : "Added system notifications to inform users about critical plugin updates and potential issues with available workarounds"
-}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 57b8cb5c722..a655c54f3eb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,9 @@
+# _3.43_ (2024-12-03)
+- **(Feature)** `/review` in Q chat to scan your code for vulnerabilities and quality issues, and generate fixes
+- **(Feature)** `/test` in Q chat to generate unit tests for java and python
+- **(Feature)** `/doc` in Q chat to generate and update documentation for your project
+- **(Feature)** Added system notifications to inform users about critical plugin updates and potential issues with available workarounds
+
# _3.42_ (2024-11-27)
- **(Feature)** Amazon Q /dev: support `Dockerfile` files
- **(Feature)** Feature(Amazon Q Code Transformation): allow users to view results in 5 smaller diffs
diff --git a/gradle.properties b/gradle.properties
index b7feae18ad7..091b254948c 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -2,7 +2,7 @@
# SPDX-License-Identifier: Apache-2.0
# Toolkit Version
-toolkitVersion=3.43-SNAPSHOT
+toolkitVersion=3.43
# Publish Settings
publishToken=
From e0ddf0bfc7c647e06b3d274c4d4c6c2cc6c9f224 Mon Sep 17 00:00:00 2001
From: aws-toolkit-automation <>
Date: Tue, 3 Dec 2024 15:46:55 +0000
Subject: [PATCH 003/117] Updating SNAPSHOT version to 3.44-SNAPSHOT
---
gradle.properties | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gradle.properties b/gradle.properties
index 091b254948c..b55e91a4594 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -2,7 +2,7 @@
# SPDX-License-Identifier: Apache-2.0
# Toolkit Version
-toolkitVersion=3.43
+toolkitVersion=3.44-SNAPSHOT
# Publish Settings
publishToken=
From d651a4604b68e205a7c19f3cc76686d128238610 Mon Sep 17 00:00:00 2001
From: Richard Li <742829+rli@users.noreply.github.com>
Date: Tue, 11 Feb 2025 16:58:06 -0800
Subject: [PATCH 004/117] feat(q): stub out Q LSP logic (#5352)
This is the initial iteration of the bare minimum of process management to interact with Amazon Q logic vended by Flare.
It is a direct port of the exploratory work done in https://github.com/rli/lsp-exp/tree/master/src/main/kotlin/org/example/lsp4j
Missing are tests and any sort of edge case handling
---
.../jetbrains-community/build.gradle.kts | 5 +
.../amazonq/lsp/AmazonQLanguageClient.kt | 18 ++
.../amazonq/lsp/AmazonQLanguageClientImpl.kt | 64 +++++++
.../amazonq/lsp/AmazonQLanguageServer.kt | 19 +++
.../services/amazonq/lsp/AmazonQLspService.kt | 160 ++++++++++++++++++
.../aws/credentials/ConnectionMetadata.kt | 12 ++
.../credentials/UpdateCredentialsPayload.kt | 17 ++
7 files changed, 295 insertions(+)
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/ConnectionMetadata.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/UpdateCredentialsPayload.kt
diff --git a/plugins/amazonq/shared/jetbrains-community/build.gradle.kts b/plugins/amazonq/shared/jetbrains-community/build.gradle.kts
index 315356836b5..8139a8c4532 100644
--- a/plugins/amazonq/shared/jetbrains-community/build.gradle.kts
+++ b/plugins/amazonq/shared/jetbrains-community/build.gradle.kts
@@ -22,5 +22,10 @@ dependencies {
implementation(libs.commons.collections)
implementation(libs.nimbus.jose.jwt)
+ // FIX_WHEN_MIN_IS_242
+ if (providers.gradleProperty("ideProfileName").get() == "2024.1") {
+ implementation("org.eclipse.lsp4j:org.eclipse.lsp4j:0.24.0")
+ }
+
testFixturesApi(testFixtures(project(":plugin-core:jetbrains-community")))
}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt
new file mode 100644
index 00000000000..8932881568f
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt
@@ -0,0 +1,18 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp
+
+import org.eclipse.lsp4j.jsonrpc.services.JsonRequest
+import org.eclipse.lsp4j.services.LanguageClient
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Requests sent by server to client
+ */
+@Suppress("unused")
+interface AmazonQLanguageClient : LanguageClient {
+ @JsonRequest("aws/credentials/getConnectionMetadata")
+ fun getConnectionMetadata(): CompletableFuture
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt
new file mode 100644
index 00000000000..ce8aa6368a4
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt
@@ -0,0 +1,64 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp
+
+import com.intellij.notification.NotificationType
+import org.eclipse.lsp4j.ConfigurationParams
+import org.eclipse.lsp4j.MessageActionItem
+import org.eclipse.lsp4j.MessageParams
+import org.eclipse.lsp4j.MessageType
+import org.eclipse.lsp4j.PublishDiagnosticsParams
+import org.eclipse.lsp4j.ShowMessageRequestParams
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.SsoProfileData
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Concrete implementation of [AmazonQLanguageClient] to handle messages sent from server
+ */
+class AmazonQLanguageClientImpl : AmazonQLanguageClient {
+ override fun telemetryEvent(`object`: Any) {
+ println(`object`)
+ }
+
+ override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) {
+ println(diagnostics)
+ }
+
+ override fun showMessage(messageParams: MessageParams) {
+ val type = when (messageParams.type) {
+ MessageType.Error -> NotificationType.ERROR
+ MessageType.Warning -> NotificationType.WARNING
+ MessageType.Info, MessageType.Log -> NotificationType.INFORMATION
+ }
+ println("$type: ${messageParams.message}")
+ }
+
+ override fun showMessageRequest(requestParams: ShowMessageRequestParams): CompletableFuture? {
+ println(requestParams)
+
+ return CompletableFuture.completedFuture(null)
+ }
+
+ override fun logMessage(message: MessageParams) {
+ showMessage(message)
+ }
+
+ override fun getConnectionMetadata() = CompletableFuture.completedFuture(
+ ConnectionMetadata(
+ SsoProfileData("TODO")
+ )
+ )
+
+ override fun configuration(params: ConfigurationParams): CompletableFuture> {
+ if (params.items.isEmpty()) {
+ return CompletableFuture.completedFuture(null)
+ }
+
+ return CompletableFuture.completedFuture(
+ buildList {
+ }
+ )
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt
new file mode 100644
index 00000000000..6a40867a7e0
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt
@@ -0,0 +1,19 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp
+
+import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage
+import org.eclipse.lsp4j.jsonrpc.services.JsonRequest
+import org.eclipse.lsp4j.services.LanguageServer
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.UpdateCredentialsPayload
+import java.util.concurrent.CompletableFuture
+
+/**
+ * Remote interface exposed by the Amazon Q language server
+ */
+@Suppress("unused")
+interface AmazonQLanguageServer : LanguageServer {
+ @JsonRequest("aws/credentials/token/update")
+ fun updateTokenCredentials(payload: UpdateCredentialsPayload): CompletableFuture
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
new file mode 100644
index 00000000000..16b9d8810b6
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
@@ -0,0 +1,160 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp
+
+import com.google.gson.ToNumberPolicy
+import com.intellij.execution.configurations.GeneralCommandLine
+import com.intellij.execution.impl.ExecutionManagerImpl
+import com.intellij.execution.process.KillableColoredProcessHandler
+import com.intellij.execution.process.KillableProcessHandler
+import com.intellij.execution.process.ProcessEvent
+import com.intellij.execution.process.ProcessListener
+import com.intellij.execution.process.ProcessOutputType
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.components.service
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.Key
+import com.intellij.util.io.await
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import org.eclipse.lsp4j.InitializeParams
+import org.eclipse.lsp4j.InitializedParams
+import org.eclipse.lsp4j.jsonrpc.Launcher
+import org.eclipse.lsp4j.launch.LSPLauncher
+import org.slf4j.event.Level
+import software.aws.toolkits.core.utils.getLogger
+import software.aws.toolkits.core.utils.warn
+import software.aws.toolkits.jetbrains.isDeveloperMode
+import java.io.IOException
+import java.io.OutputStreamWriter
+import java.io.PipedInputStream
+import java.io.PipedOutputStream
+import java.io.PrintWriter
+import java.io.StringWriter
+import java.nio.charset.StandardCharsets
+import java.util.concurrent.Future
+
+// https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/server/LSPProcessListener.java
+// JB impl and redhat both use a wrapper to handle input buffering issue
+internal class LSPProcessListener : ProcessListener {
+ private val outputStream = PipedOutputStream()
+ private val outputStreamWriter = OutputStreamWriter(outputStream, StandardCharsets.UTF_8)
+ val inputStream = PipedInputStream(outputStream)
+
+ override fun onTextAvailable(event: ProcessEvent, outputType: Key<*>) {
+ if (ProcessOutputType.isStdout(outputType)) {
+ try {
+ this.outputStreamWriter.write(event.text)
+ this.outputStreamWriter.flush()
+ } catch (_: IOException) {
+ ExecutionManagerImpl.stopProcess(event.processHandler)
+ }
+ } else if (ProcessOutputType.isStderr(outputType)) {
+ LOG.warn { "LSP process stderr: ${event.text}" }
+ }
+ }
+
+ override fun processTerminated(event: ProcessEvent) {
+ try {
+ this.outputStreamWriter.close()
+ this.outputStream.close()
+ } catch (_: IOException) {
+ }
+ }
+
+ companion object {
+ private val LOG = getLogger()
+ }
+}
+
+@Service(Service.Level.PROJECT)
+class AmazonQLspService(project: Project, private val cs: CoroutineScope) : Disposable {
+ private val launcher: Launcher
+
+ private val languageServer: AmazonQLanguageServer
+ get() = launcher.remoteProxy
+
+ @Suppress("ForbiddenVoid")
+ private val launcherFuture: Future
+ private val launcherHandler: KillableProcessHandler
+
+ init {
+ val cmd = GeneralCommandLine("amazon-q-lsp")
+
+ launcherHandler = KillableColoredProcessHandler.Silent(cmd)
+ val inputWrapper = LSPProcessListener()
+ launcherHandler.addProcessListener(inputWrapper)
+ launcherHandler.startNotify()
+
+ launcher = LSPLauncher.Builder()
+ .setLocalService(AmazonQLanguageClientImpl())
+ .setRemoteInterface(AmazonQLanguageServer::class.java)
+ .configureGson {
+ // TODO: maybe need adapter for initialize:
+ // https://github.com/aws/amazon-q-eclipse/blob/b9d5bdcd5c38e1dd8ad371d37ab93a16113d7d4b/plugin/src/software/aws/toolkits/eclipse/amazonq/lsp/QLspTypeAdapterFactory.java
+
+ // otherwise Gson treats all numbers as double which causes deser issues
+ it.setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE)
+ }.traceMessages(
+ PrintWriter(
+ object : StringWriter() {
+ private val traceLogger = LOG.atLevel(if (isDeveloperMode()) Level.INFO else Level.DEBUG)
+
+ override fun flush() {
+ traceLogger.log { buffer.toString() }
+ buffer.setLength(0)
+ }
+ }
+ )
+ )
+ .setInput(inputWrapper.inputStream)
+ .setOutput(launcherHandler.process.outputStream)
+ .create()
+
+ launcherFuture = launcher.startListening()
+
+ cs.launch {
+ val initializeResult = languageServer.initialize(
+ InitializeParams().apply {
+ // does this work on windows
+ processId = ProcessHandle.current().pid().toInt()
+ // capabilities
+ // client info
+ // trace?
+ // workspace folders?
+ // anything else we need?
+ }
+ // probably need a timeout
+ ).await()
+
+ // then if this succeeds then we can allow the client to send requests
+ if (initializeResult == null) {
+ LOG.warn { "LSP initialization failed" }
+ launcherHandler.destroyProcess()
+ }
+ languageServer.initialized(InitializedParams())
+ }
+ }
+
+ override fun dispose() {
+ if (!launcherFuture.isDone) {
+ try {
+ languageServer.apply {
+ shutdown().thenRun { exit() }
+ }
+ } catch (e: Exception) {
+ LOG.warn(e) { "LSP shutdown failed" }
+ launcherHandler.destroyProcess()
+ }
+ } else if (!launcherHandler.isProcessTerminated) {
+ launcherHandler.destroyProcess()
+ }
+ }
+
+ companion object {
+ private val LOG = getLogger()
+ fun getInstance(project: Project) = project.service()
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/ConnectionMetadata.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/ConnectionMetadata.kt
new file mode 100644
index 00000000000..c6216b97cff
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/ConnectionMetadata.kt
@@ -0,0 +1,12 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials
+
+data class ConnectionMetadata(
+ val sso: SsoProfileData,
+)
+
+data class SsoProfileData(
+ val startUrl: String,
+)
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/UpdateCredentialsPayload.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/UpdateCredentialsPayload.kt
new file mode 100644
index 00000000000..2b5a28b6cb4
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/UpdateCredentialsPayload.kt
@@ -0,0 +1,17 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials
+
+data class UpdateCredentialsPayload(
+ val data: String,
+ val encrypted: String,
+)
+
+data class UpdateCredentialsPayloadData(
+ val data: BearerCredentials,
+)
+
+data class BearerCredentials(
+ val token: String,
+)
From 013c54f091b6c16f46b489dd985f8ca6ab631e4a Mon Sep 17 00:00:00 2001
From: Sam Stewart
Date: Wed, 12 Feb 2025 16:17:29 -0800
Subject: [PATCH 005/117] feat(amazonq): LSP -- Implement Initialize message
(#5367)
Initialize message is the first request sent from the client and sets up the LSP session configuration for subsequent communications.
Includes:
Process ID for server management
Client capabilities for LSP features
Client information (IDE/product details)
Workspace folders for project context
Extended client metadata specific to Amazon Q
***needs tests, not currently working
---
.../services/amazonq/lsp/AmazonQLspService.kt | 85 ++++++++++++++++---
.../lsp/model/ExtendedClientMetadata.kt | 43 ++++++++++
2 files changed, 115 insertions(+), 13 deletions(-)
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/ExtendedClientMetadata.kt
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
index 16b9d8810b6..0181e70591b 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
@@ -18,24 +18,36 @@ import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Key
import com.intellij.util.io.await
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch
+import kotlinx.coroutines.time.withTimeout
+import org.eclipse.lsp4j.ClientCapabilities
+import org.eclipse.lsp4j.ClientInfo
+import org.eclipse.lsp4j.FileOperationsWorkspaceCapabilities
import org.eclipse.lsp4j.InitializeParams
import org.eclipse.lsp4j.InitializedParams
+import org.eclipse.lsp4j.SynchronizationCapabilities
+import org.eclipse.lsp4j.TextDocumentClientCapabilities
+import org.eclipse.lsp4j.WorkspaceClientCapabilities
+import org.eclipse.lsp4j.WorkspaceFolder
import org.eclipse.lsp4j.jsonrpc.Launcher
import org.eclipse.lsp4j.launch.LSPLauncher
import org.slf4j.event.Level
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.isDeveloperMode
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.createExtendedClientMetadata
+import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata
import java.io.IOException
import java.io.OutputStreamWriter
import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.io.PrintWriter
import java.io.StringWriter
+import java.net.URI
import java.nio.charset.StandardCharsets
+import java.time.Duration
import java.util.concurrent.Future
-
// https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/server/LSPProcessListener.java
// JB impl and redhat both use a wrapper to handle input buffering issue
internal class LSPProcessListener : ProcessListener {
@@ -70,7 +82,7 @@ internal class LSPProcessListener : ProcessListener {
}
@Service(Service.Level.PROJECT)
-class AmazonQLspService(project: Project, private val cs: CoroutineScope) : Disposable {
+class AmazonQLspService(private val project: Project, private val cs: CoroutineScope) : Disposable {
private val launcher: Launcher
private val languageServer: AmazonQLanguageServer
@@ -80,6 +92,57 @@ class AmazonQLspService(project: Project, private val cs: CoroutineScope) : Disp
private val launcherFuture: Future
private val launcherHandler: KillableProcessHandler
+ private fun createClientCapabilities(): ClientCapabilities =
+ ClientCapabilities().apply {
+ textDocument = TextDocumentClientCapabilities().apply {
+ // For didSaveTextDocument, other textDocument/ messages always mandatory
+ synchronization = SynchronizationCapabilities().apply {
+ didSave = true
+ }
+ }
+
+ workspace = WorkspaceClientCapabilities().apply {
+ applyEdit = false
+
+ // For workspace folder changes
+ workspaceFolders = true
+
+ // For file operations (create, delete)
+ fileOperations = FileOperationsWorkspaceCapabilities().apply {
+ didCreate = true
+ didDelete = true
+ }
+ }
+ }
+
+ // needs case handling when project's base path is null: default projects/unit tests
+ private fun createWorkspaceFolders(): List =
+ project.basePath?.let { basePath ->
+ listOf(
+ WorkspaceFolder(
+ URI("file://$basePath").toString(),
+ project.name
+ )
+ )
+ }.orEmpty() // no folders to report or workspace not folder based
+
+ private fun createClientInfo(): ClientInfo {
+ val metadata = ClientMetadata.getDefault()
+ return ClientInfo().apply {
+ name = metadata.awsProduct.toString()
+ version = metadata.awsVersion
+ }
+ }
+
+ private fun createInitializeParams(): InitializeParams =
+ InitializeParams().apply {
+ processId = ProcessHandle.current().pid().toInt()
+ capabilities = createClientCapabilities()
+ clientInfo = createClientInfo()
+ workspaceFolders = createWorkspaceFolders()
+ initializationOptions = createExtendedClientMetadata()
+ }
+
init {
val cmd = GeneralCommandLine("amazon-q-lsp")
@@ -116,18 +179,14 @@ class AmazonQLspService(project: Project, private val cs: CoroutineScope) : Disp
launcherFuture = launcher.startListening()
cs.launch {
- val initializeResult = languageServer.initialize(
- InitializeParams().apply {
- // does this work on windows
- processId = ProcessHandle.current().pid().toInt()
- // capabilities
- // client info
- // trace?
- // workspace folders?
- // anything else we need?
+ val initializeResult = try {
+ withTimeout(Duration.ofSeconds(30)) {
+ languageServer.initialize(createInitializeParams()).await()
}
- // probably need a timeout
- ).await()
+ } catch (e: TimeoutCancellationException) {
+ LOG.warn { "LSP initialization timed out" }
+ null
+ }
// then if this succeeds then we can allow the client to send requests
if (initializeResult == null) {
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/ExtendedClientMetadata.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/ExtendedClientMetadata.kt
new file mode 100644
index 00000000000..6672a70408f
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/ExtendedClientMetadata.kt
@@ -0,0 +1,43 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.model
+
+import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata
+
+data class ExtendedClientMetadata(
+ val aws: AwsMetadata,
+)
+
+data class AwsMetadata(
+ val clientInfo: ClientInfoMetadata,
+)
+
+data class ClientInfoMetadata(
+ val extension: ExtensionMetadata,
+ val clientId: String,
+ val version: String,
+ val name: String,
+)
+
+data class ExtensionMetadata(
+ val name: String,
+ val version: String,
+)
+
+fun createExtendedClientMetadata(): ExtendedClientMetadata {
+ val metadata = ClientMetadata.getDefault()
+ return ExtendedClientMetadata(
+ aws = AwsMetadata(
+ clientInfo = ClientInfoMetadata(
+ extension = ExtensionMetadata(
+ name = metadata.awsProduct.toString(),
+ version = metadata.awsVersion
+ ),
+ clientId = metadata.clientId,
+ version = metadata.parentProductVersion,
+ name = metadata.parentProduct
+ )
+ )
+ )
+}
From d9f7bb3b1b91e210d767f3c1419dfb36e1881eca Mon Sep 17 00:00:00 2001
From: Richard Li <742829+rli@users.noreply.github.com>
Date: Wed, 12 Feb 2025 16:54:41 -0800
Subject: [PATCH 006/117] feat(amazonq): initialize flare lsp on project start
(#5366)
---
.../amazonq/startup/AmazonQStartupActivity.kt | 2 ++
.../services/amazonq/lsp/AmazonQLspService.kt | 35 ++++++++++++++++---
2 files changed, 33 insertions(+), 4 deletions(-)
diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt
index f114ae98239..b6b9aa29c9d 100644
--- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt
+++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/startup/AmazonQStartupActivity.kt
@@ -19,6 +19,7 @@ import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager
import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection
import software.aws.toolkits.jetbrains.core.gettingstarted.emitUserState
import software.aws.toolkits.jetbrains.services.amazonq.CodeWhispererFeatureConfigService
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService
import software.aws.toolkits.jetbrains.services.amazonq.project.ProjectContextController
import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindow
import software.aws.toolkits.jetbrains.services.amazonq.toolwindow.AmazonQToolWindowFactory
@@ -52,6 +53,7 @@ class AmazonQStartupActivity : ProjectActivity {
CodeWhispererExplorerActionManager.getInstance().setIsFirstRestartAfterQInstall(false)
}
}
+ AmazonQLspService.getInstance(project)
startLsp(project)
if (runOnce.get()) return
emitUserState(project)
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
index 0181e70591b..44a6896e0bb 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
@@ -15,6 +15,7 @@ import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
+import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.Key
import com.intellij.util.io.await
import kotlinx.coroutines.CoroutineScope
@@ -48,6 +49,7 @@ import java.net.URI
import java.nio.charset.StandardCharsets
import java.time.Duration
import java.util.concurrent.Future
+
// https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/server/LSPProcessListener.java
// JB impl and redhat both use a wrapper to handle input buffering issue
internal class LSPProcessListener : ProcessListener {
@@ -83,6 +85,32 @@ internal class LSPProcessListener : ProcessListener {
@Service(Service.Level.PROJECT)
class AmazonQLspService(private val project: Project, private val cs: CoroutineScope) : Disposable {
+ private var instance: AmazonQServerInstance? = null
+
+ init {
+ cs.launch {
+ // manage lifecycle RAII-like so we can restart at arbitrary time
+ // and suppress IDE error if server fails to start
+ try {
+ instance = AmazonQServerInstance(project, cs).also {
+ Disposer.register(this@AmazonQLspService, it)
+ }
+ } catch (e: Exception) {
+ LOG.warn(e) { "Failed to start LSP server" }
+ }
+ }
+ }
+
+ override fun dispose() {
+ }
+
+ companion object {
+ private val LOG = getLogger()
+ fun getInstance(project: Project) = project.service()
+ }
+}
+
+private class AmazonQServerInstance(private val project: Project, private val cs: CoroutineScope) : Disposable {
private val launcher: Launcher
private val languageServer: AmazonQLanguageServer
@@ -180,10 +208,10 @@ class AmazonQLspService(private val project: Project, private val cs: CoroutineS
cs.launch {
val initializeResult = try {
- withTimeout(Duration.ofSeconds(30)) {
+ withTimeout(Duration.ofSeconds(10)) {
languageServer.initialize(createInitializeParams()).await()
}
- } catch (e: TimeoutCancellationException) {
+ } catch (_: TimeoutCancellationException) {
LOG.warn { "LSP initialization timed out" }
null
}
@@ -213,7 +241,6 @@ class AmazonQLspService(private val project: Project, private val cs: CoroutineS
}
companion object {
- private val LOG = getLogger()
- fun getInstance(project: Project) = project.service()
+ private val LOG = getLogger()
}
}
From 3a866a66a7d80bd008a5b37e0f17bb219b46a43b Mon Sep 17 00:00:00 2001
From: Richard Li <742829+rli@users.noreply.github.com>
Date: Wed, 12 Feb 2025 17:56:06 -0800
Subject: [PATCH 007/117] fix(amazonq): also terminate lsp process on other
failures (#5371)
---
.../jetbrains/services/amazonq/lsp/AmazonQLspService.kt | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
index 44a6896e0bb..4bd37bc058a 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
@@ -214,11 +214,13 @@ private class AmazonQServerInstance(private val project: Project, private val cs
} catch (_: TimeoutCancellationException) {
LOG.warn { "LSP initialization timed out" }
null
+ } catch (e: Exception) {
+ LOG.warn(e) { "LSP initialization failed" }
+ null
}
// then if this succeeds then we can allow the client to send requests
if (initializeResult == null) {
- LOG.warn { "LSP initialization failed" }
launcherHandler.destroyProcess()
}
languageServer.initialized(InitializedParams())
From e22092ff503fcd49011090e10cdfa777c11273b7 Mon Sep 17 00:00:00 2001
From: Richard Li <742829+rli@users.noreply.github.com>
Date: Thu, 13 Feb 2025 13:10:32 -0800
Subject: [PATCH 008/117] feat(amazonq): hook up lsp payload encryption (#5370)
---
.../services/amazonq/lsp/AmazonQLspService.kt | 12 ++-
.../lsp/encryption/JwtEncryptionManager.kt | 65 ++++++++++++++++
.../model/EncryptionInitializationRequest.kt | 20 +++++
.../credentials/UpdateCredentialsPayload.kt | 2 +-
.../encryption/JwtEncryptionManagerTest.kt | 77 +++++++++++++++++++
5 files changed, 174 insertions(+), 2 deletions(-)
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManager.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/EncryptionInitializationRequest.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManagerTest.kt
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
index 4bd37bc058a..4387f99ce8d 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
@@ -37,6 +37,7 @@ import org.slf4j.event.Level
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.isDeveloperMode
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.createExtendedClientMetadata
import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata
import java.io.IOException
@@ -111,6 +112,8 @@ class AmazonQLspService(private val project: Project, private val cs: CoroutineS
}
private class AmazonQServerInstance(private val project: Project, private val cs: CoroutineScope) : Disposable {
+ private val encryptionManager = JwtEncryptionManager()
+
private val launcher: Launcher
private val languageServer: AmazonQLanguageServer
@@ -172,7 +175,11 @@ private class AmazonQServerInstance(private val project: Project, private val cs
}
init {
- val cmd = GeneralCommandLine("amazon-q-lsp")
+ val cmd = GeneralCommandLine(
+ "amazon-q-lsp",
+ "--stdio",
+ "--set-credentials-encryption-key",
+ )
launcherHandler = KillableColoredProcessHandler.Silent(cmd)
val inputWrapper = LSPProcessListener()
@@ -207,6 +214,9 @@ private class AmazonQServerInstance(private val project: Project, private val cs
launcherFuture = launcher.startListening()
cs.launch {
+ // encryption info must be sent within 5s or Flare process will exit
+ encryptionManager.writeInitializationPayload(launcherHandler.process.outputStream)
+
val initializeResult = try {
withTimeout(Duration.ofSeconds(10)) {
languageServer.initialize(createInitializeParams()).await()
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManager.kt
new file mode 100644
index 00000000000..bca385682ea
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManager.kt
@@ -0,0 +1,65 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption
+
+import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
+import com.nimbusds.jose.EncryptionMethod
+import com.nimbusds.jose.JWEAlgorithm
+import com.nimbusds.jose.JWEHeader
+import com.nimbusds.jose.JWEObject
+import com.nimbusds.jose.Payload
+import com.nimbusds.jose.crypto.DirectDecrypter
+import com.nimbusds.jose.crypto.DirectEncrypter
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.EncryptionInitializationRequest
+import java.io.OutputStream
+import java.security.SecureRandom
+import java.util.Base64
+import javax.crypto.SecretKey
+import javax.crypto.spec.SecretKeySpec
+
+class JwtEncryptionManager(private val key: SecretKey) {
+ constructor() : this(generateHmacKey())
+
+ private val mapper = jacksonObjectMapper()
+
+ fun writeInitializationPayload(os: OutputStream) {
+ val payload = EncryptionInitializationRequest(
+ EncryptionInitializationRequest.Version.V1_0,
+ EncryptionInitializationRequest.Mode.JWT,
+ Base64.getUrlEncoder().withoutPadding().encodeToString(key.encoded)
+ )
+
+ // write directly to stream because utils are closing the underlying stream
+ os.write("${mapper.writeValueAsString(payload)}\n".toByteArray())
+ }
+
+ fun encrypt(data: Any): String {
+ val header = JWEHeader(JWEAlgorithm.DIR, EncryptionMethod.A256GCM)
+ val payload = if (data is String) {
+ Payload(data)
+ } else {
+ Payload(mapper.writeValueAsBytes(data))
+ }
+
+ val jweObject = JWEObject(header, payload)
+ jweObject.encrypt(DirectEncrypter(key))
+
+ return jweObject.serialize()
+ }
+
+ fun decrypt(jwt: String): String {
+ val jweObject = JWEObject.parse(jwt)
+ jweObject.decrypt(DirectDecrypter(key))
+
+ return jweObject.payload.toString()
+ }
+
+ private companion object {
+ private fun generateHmacKey(): SecretKey {
+ val keyBytes = ByteArray(32)
+ SecureRandom().nextBytes(keyBytes)
+ return SecretKeySpec(keyBytes, "HmacSHA256")
+ }
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/EncryptionInitializationRequest.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/EncryptionInitializationRequest.kt
new file mode 100644
index 00000000000..53748475195
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/EncryptionInitializationRequest.kt
@@ -0,0 +1,20 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.model
+
+import com.fasterxml.jackson.annotation.JsonValue
+
+data class EncryptionInitializationRequest(
+ val version: Version,
+ val mode: Mode,
+ val key: String,
+) {
+ enum class Version(@JsonValue val value: String) {
+ V1_0("1.0"),
+ }
+
+ enum class Mode(@JsonValue val value: String) {
+ JWT("JWT"),
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/UpdateCredentialsPayload.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/UpdateCredentialsPayload.kt
index 2b5a28b6cb4..a427330c055 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/UpdateCredentialsPayload.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/credentials/UpdateCredentialsPayload.kt
@@ -5,7 +5,7 @@ package software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentia
data class UpdateCredentialsPayload(
val data: String,
- val encrypted: String,
+ val encrypted: Boolean,
)
data class UpdateCredentialsPayloadData(
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManagerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManagerTest.kt
new file mode 100644
index 00000000000..365fd2759b0
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/encryption/JwtEncryptionManagerTest.kt
@@ -0,0 +1,77 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption
+
+import com.nimbusds.jose.JOSEException
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.assertThrows
+import java.io.ByteArrayOutputStream
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.crypto.spec.SecretKeySpec
+import kotlin.random.Random
+
+class JwtEncryptionManagerTest {
+ @Test
+ fun `uses a different encryption key for each instance`() {
+ val blob = Random.Default.nextBytes(256)
+ val sut1 = JwtEncryptionManager()
+ val encrypted = sut1.encrypt(blob)
+
+ assertThrows {
+ assertThat(sut1.decrypt(encrypted))
+ .isNotEqualTo(JwtEncryptionManager().decrypt(encrypted))
+ }
+ }
+
+ @Test
+ @OptIn(ExperimentalStdlibApi::class)
+ fun `encryption is stable with static key`() {
+ val blob = Random.Default.nextBytes(256)
+ val bytes = "DEADBEEF".repeat(8).hexToByteArray() // 32 bytes
+ val key = SecretKeySpec(bytes, "HmacSHA256")
+ val sut1 = JwtEncryptionManager(key)
+ val encrypted = sut1.encrypt(blob)
+
+ // each encrypt() call will use a different IV so we can't just directly compare
+ assertThat(sut1.decrypt(encrypted))
+ .isEqualTo(JwtEncryptionManager(key).decrypt(encrypted))
+ }
+
+ @Test
+ fun `encryption can be round-tripped`() {
+ val sut = JwtEncryptionManager()
+ val blob = "DEADBEEF".repeat(8)
+ assertThat(sut.decrypt(sut.encrypt(blob))).isEqualTo(blob)
+ }
+
+ @Test
+ @OptIn(ExperimentalStdlibApi::class)
+ fun writeInitializationPayload() {
+ val bytes = "DEADBEEF".repeat(8).hexToByteArray() // 32 bytes
+ val key = SecretKeySpec(bytes, "HmacSHA256")
+
+ val closed = AtomicBoolean(false)
+ val os = object : ByteArrayOutputStream() {
+ override fun close() {
+ closed.set(true)
+ }
+ }
+ JwtEncryptionManager(key).writeInitializationPayload(os)
+ assertThat(os.toString())
+ // Flare requires encryption ends with new line
+ // https://github.com/aws/language-server-runtimes/blob/4d7f81295dc12b59ed2e1c0ebaedb85ccb86cf76/runtimes/README.md#encryption
+ .endsWith("\n")
+ .isEqualTo(
+ // language=JSON
+ """
+ |{"version":"1.0","mode":"JWT","key":"3q2-796tvu_erb7v3q2-796tvu_erb7v3q2-796tvu8"}
+ |
+ """.trimMargin()
+ )
+
+ // writeInitializationPayload should not close the stream
+ assertThat(closed.get()).isFalse
+ }
+}
From d927594e26ec22fd8898849125ae594f2e3374a1 Mon Sep 17 00:00:00 2001
From: Richard Li <742829+rli@users.noreply.github.com>
Date: Tue, 18 Feb 2025 11:48:44 -0800
Subject: [PATCH 009/117] feature(amazonq): expose `AmazonQLanguageServer` to
consumers (#5386)
This adds the command to restart the Q LSP and exposes a preliminary invocation interface for calling LSP commands outside of the project service
---
.../services/amazonq/lsp/AmazonQLspService.kt | 94 ++++++++++++++++---
1 file changed, 82 insertions(+), 12 deletions(-)
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
index 4387f99ce8d..28445578c67 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
@@ -14,14 +14,21 @@ import com.intellij.execution.process.ProcessOutputType
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
+import com.intellij.openapi.components.serviceIfCreated
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.Key
import com.intellij.util.io.await
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.async
import kotlinx.coroutines.launch
-import kotlinx.coroutines.time.withTimeout
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withTimeout
import org.eclipse.lsp4j.ClientCapabilities
import org.eclipse.lsp4j.ClientInfo
import org.eclipse.lsp4j.FileOperationsWorkspaceCapabilities
@@ -35,6 +42,7 @@ import org.eclipse.lsp4j.jsonrpc.Launcher
import org.eclipse.lsp4j.launch.LSPLauncher
import org.slf4j.event.Level
import software.aws.toolkits.core.utils.getLogger
+import software.aws.toolkits.core.utils.info
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.isDeveloperMode
import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager
@@ -48,8 +56,8 @@ import java.io.PrintWriter
import java.io.StringWriter
import java.net.URI
import java.nio.charset.StandardCharsets
-import java.time.Duration
import java.util.concurrent.Future
+import kotlin.time.Duration.Companion.seconds
// https://github.com/redhat-developer/lsp4ij/blob/main/src/main/java/com/redhat/devtools/lsp4ij/server/LSPProcessListener.java
// JB impl and redhat both use a wrapper to handle input buffering issue
@@ -86,28 +94,89 @@ internal class LSPProcessListener : ProcessListener {
@Service(Service.Level.PROJECT)
class AmazonQLspService(private val project: Project, private val cs: CoroutineScope) : Disposable {
- private var instance: AmazonQServerInstance? = null
+ private var instance: Deferred
- init {
- cs.launch {
- // manage lifecycle RAII-like so we can restart at arbitrary time
- // and suppress IDE error if server fails to start
+ // dont allow lsp commands if server is restarting
+ private val mutex = Mutex(false)
+
+ private fun start() = cs.async {
+ // manage lifecycle RAII-like so we can restart at arbitrary time
+ // and suppress IDE error if server fails to start
+ var attempts = 0
+ while (attempts < 3) {
try {
- instance = AmazonQServerInstance(project, cs).also {
- Disposer.register(this@AmazonQLspService, it)
+ return@async withTimeout(30.seconds) {
+ val instance = AmazonQServerInstance(project, cs).also {
+ Disposer.register(this@AmazonQLspService, it)
+ }
+ // wait for handshake to complete
+ instance.initializer.join()
+
+ instance
}
} catch (e: Exception) {
LOG.warn(e) { "Failed to start LSP server" }
}
+ attempts++
}
+
+ error("Failed to start LSP server in 3 attempts")
+ }
+
+ init {
+ instance = start()
}
override fun dispose() {
}
+ suspend fun restart() = mutex.withLock {
+ // stop if running
+ instance.let {
+ if (it.isActive) {
+ // not even running yet
+ return
+ }
+
+ try {
+ val i = it.await()
+ if (i.initializer.isActive) {
+ // not initialized
+ return
+ }
+
+ Disposer.dispose(i)
+ } catch (e: Exception) {
+ LOG.info(e) { "Exception while disposing LSP server" }
+ }
+ }
+
+ instance = start()
+ }
+
+ suspend fun execute(runnable: suspend (AmazonQLanguageServer) -> Unit) {
+ val lsp = withTimeout(10.seconds) {
+ val holder = mutex.withLock { instance }.await()
+ holder.initializer.join()
+
+ holder.languageServer
+ }
+
+ runnable(lsp)
+ }
+
+ fun executeSync(runnable: suspend (AmazonQLanguageServer) -> Unit) {
+ runBlocking(cs.coroutineContext) {
+ execute(runnable)
+ }
+ }
+
companion object {
private val LOG = getLogger()
fun getInstance(project: Project) = project.service()
+
+ fun executeIfRunning(project: Project, runnable: (AmazonQLanguageServer) -> Unit) =
+ project.serviceIfCreated()?.executeSync(runnable)
}
}
@@ -116,12 +185,13 @@ private class AmazonQServerInstance(private val project: Project, private val cs
private val launcher: Launcher
- private val languageServer: AmazonQLanguageServer
+ val languageServer: AmazonQLanguageServer
get() = launcher.remoteProxy
@Suppress("ForbiddenVoid")
private val launcherFuture: Future
private val launcherHandler: KillableProcessHandler
+ val initializer: Job
private fun createClientCapabilities(): ClientCapabilities =
ClientCapabilities().apply {
@@ -213,12 +283,12 @@ private class AmazonQServerInstance(private val project: Project, private val cs
launcherFuture = launcher.startListening()
- cs.launch {
+ initializer = cs.launch {
// encryption info must be sent within 5s or Flare process will exit
encryptionManager.writeInitializationPayload(launcherHandler.process.outputStream)
val initializeResult = try {
- withTimeout(Duration.ofSeconds(10)) {
+ withTimeout(5.seconds) {
languageServer.initialize(createInitializeParams()).await()
}
} catch (_: TimeoutCancellationException) {
From 179aea299b88ae79452a6df7d99d8a9d86c88368 Mon Sep 17 00:00:00 2001
From: Lokesh
Date: Wed, 26 Feb 2025 13:45:28 -0800
Subject: [PATCH 010/117] feat(amazonq): Added LSP Manifest manager related
changes (#5387)
* Added Manifest Fetcher
* Addressing code review comments
* Added unit test cases
* Fixing lint issues
* Addressing code review comments
* Addressing code review comments
* Fixing lint issues
* Addressing code review comments
* Fixing detektMain lint issues
* Added unit test cases
* Updating code according to spec.
* detekt
* Fixing typo
* Artifact changes
* Fixing validation function
* Addressing code review comments
* Fixing Detekt
---
.../amazonq/lsp/artifacts/ArtifactHelper.kt | 187 ++++++++++++++++++
.../amazonq/lsp/artifacts/ArtifactManager.kt | 111 +++++++++++
.../amazonq/lsp/artifacts/LspException.kt | 20 ++
.../amazonq/lsp/artifacts/LspUtils.kt | 68 +++++++
.../amazonq/lsp/artifacts/ManifestFetcher.kt | 111 +++++++++++
.../project/manifest/ManifestManager.kt | 11 +-
.../lsp/artifacts/ManifestFetcherTest.kt | 100 ++++++++++
.../DefaultRemoteResourceResolverProvider.kt | 7 +-
.../aws/toolkits/jetbrains/core/HttpUtils.kt | 7 +
9 files changed, 609 insertions(+), 13 deletions(-)
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt
new file mode 100644
index 00000000000..00cc5923195
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt
@@ -0,0 +1,187 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
+
+import com.intellij.util.io.createDirectories
+import com.intellij.util.text.SemVer
+import software.aws.toolkits.core.utils.deleteIfExists
+import software.aws.toolkits.core.utils.error
+import software.aws.toolkits.core.utils.exists
+import software.aws.toolkits.core.utils.getLogger
+import software.aws.toolkits.core.utils.info
+import software.aws.toolkits.core.utils.warn
+import software.aws.toolkits.jetbrains.core.saveFileFromUrl
+import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
+import java.nio.file.Path
+import java.util.concurrent.atomic.AtomicInteger
+
+class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH, private val maxDownloadAttempts: Int = MAX_DOWNLOAD_ATTEMPTS) {
+
+ companion object {
+ private val DEFAULT_ARTIFACT_PATH = getToolkitsCommonCacheRoot().resolve("aws").resolve("toolkits").resolve("language-servers")
+ private val logger = getLogger()
+ private const val MAX_DOWNLOAD_ATTEMPTS = 3
+ }
+ private val currentAttempt = AtomicInteger(0)
+
+ fun removeDelistedVersions(delistedVersions: List) {
+ val localFolders = getSubFolders(lspArtifactsPath)
+
+ delistedVersions.forEach { delistedVersion ->
+ val versionToDelete = delistedVersion.serverVersion ?: return@forEach
+
+ localFolders
+ .filter { folder -> folder.fileName.toString() == versionToDelete }
+ .forEach { folder ->
+ try {
+ folder.toFile().deleteRecursively()
+ logger.info { "Successfully deleted deListed version: ${folder.fileName}" }
+ } catch (e: Exception) {
+ logger.error(e) { "Failed to delete deListed version ${folder.fileName}: ${e.message}" }
+ }
+ }
+ }
+ }
+
+ fun deleteOlderLspArtifacts(manifestVersionRanges: ArtifactManager.SupportedManifestVersionRange) {
+ val localFolders = getSubFolders(lspArtifactsPath)
+
+ val validVersions = localFolders
+ .mapNotNull { localFolder ->
+ SemVer.parseFromText(localFolder.fileName.toString())?.let { semVer ->
+ if (semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion) {
+ localFolder to semVer
+ } else {
+ null
+ }
+ }
+ }
+ .sortedByDescending { (_, semVer) -> semVer }
+
+ // Keep the latest 2 versions, delete others
+ validVersions.drop(2).forEach { (folder, _) ->
+ try {
+ folder.toFile().deleteRecursively()
+ logger.info { "Deleted older LSP artifact: ${folder.fileName}" }
+ } catch (e: Exception) {
+ logger.error(e) { "Failed to delete older LSP artifact: ${folder.fileName}" }
+ }
+ }
+ }
+
+ fun getExistingLspArtifacts(versions: List, target: ManifestManager.VersionTarget?): Boolean {
+ if (versions.isEmpty() || target?.contents == null) return false
+
+ val localLSPPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString())
+ if (!localLSPPath.exists()) return false
+
+ val hasInvalidFiles = target.contents.any { content ->
+ content.filename?.let { filename ->
+ val filePath = localLSPPath.resolve(filename)
+ !filePath.exists() || !validateFileHash(filePath, content.hashes?.firstOrNull())
+ } ?: false
+ }
+
+ if (hasInvalidFiles) {
+ try {
+ localLSPPath.toFile().deleteRecursively()
+ logger.info { "Deleted mismatched LSP artifacts at: $localLSPPath" }
+ } catch (e: Exception) {
+ logger.error(e) { "Failed to delete mismatched LSP artifacts at: $localLSPPath" }
+ }
+ }
+ return !hasInvalidFiles
+ }
+
+ fun tryDownloadLspArtifacts(versions: List, target: ManifestManager.VersionTarget?) {
+ val temporaryDownloadPath = lspArtifactsPath.resolve("temp")
+ val downloadPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString())
+
+ while (currentAttempt.get() < maxDownloadAttempts) {
+ currentAttempt.incrementAndGet()
+ logger.info { "Attempt ${currentAttempt.get()} of $maxDownloadAttempts to download LSP artifacts" }
+
+ try {
+ if (downloadLspArtifacts(temporaryDownloadPath, target)) {
+ moveFilesFromSourceToDestination(temporaryDownloadPath, downloadPath)
+ logger.info { "Successfully downloaded and moved LSP artifacts to $downloadPath" }
+ return
+ }
+ } catch (e: Exception) {
+ logger.error(e) { "Failed to download/move LSP artifacts on attempt ${currentAttempt.get()}" }
+ temporaryDownloadPath.toFile().deleteRecursively()
+
+ if (currentAttempt.get() >= maxDownloadAttempts) {
+ throw LspException("Failed to download LSP artifacts after $maxDownloadAttempts attempts", LspException.ErrorCode.DOWNLOAD_FAILED)
+ }
+ }
+ }
+ }
+
+ private fun downloadLspArtifacts(downloadPath: Path, target: ManifestManager.VersionTarget?): Boolean {
+ if (target == null || target.contents.isNullOrEmpty()) {
+ logger.warn { "No target contents available for download" }
+ return false
+ }
+ try {
+ downloadPath.createDirectories()
+ target.contents.forEach { content ->
+ if (content.url == null || content.filename == null) {
+ logger.warn { "Missing URL or filename in content" }
+ return@forEach
+ }
+ val filePath = downloadPath.resolve(content.filename)
+ val contentHash = content.hashes?.firstOrNull() ?: run {
+ logger.warn { "No hash available for ${content.filename}" }
+ return@forEach
+ }
+ downloadAndValidateFile(content.url, filePath, contentHash)
+ }
+ validateDownloadedFiles(downloadPath, target.contents)
+ } catch (e: Exception) {
+ logger.error(e) { "Failed to download LSP artifacts: ${e.message}" }
+ downloadPath.toFile().deleteRecursively()
+ return false
+ }
+ return true
+ }
+
+ private fun downloadAndValidateFile(url: String, filePath: Path, expectedHash: String) {
+ try {
+ if (!filePath.exists()) {
+ logger.info { "Downloading file: ${filePath.fileName}" }
+ saveFileFromUrl(url, filePath)
+ }
+ if (!validateFileHash(filePath, expectedHash)) {
+ logger.warn { "Hash mismatch for ${filePath.fileName}, re-downloading" }
+ filePath.deleteIfExists()
+ saveFileFromUrl(url, filePath)
+ if (!validateFileHash(filePath, expectedHash)) {
+ throw LspException("Hash mismatch after re-download for ${filePath.fileName}", LspException.ErrorCode.HASH_MISMATCH)
+ }
+ }
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to download/validate file: ${filePath.fileName}", e)
+ }
+ }
+
+ private fun validateFileHash(filePath: Path, expectedHash: String?): Boolean {
+ if (expectedHash == null) return false
+ val contentHash = generateSHA384Hash(filePath)
+ return "sha384:$contentHash" == expectedHash
+ }
+
+ private fun validateDownloadedFiles(downloadPath: Path, contents: List) {
+ val missingFiles = contents
+ .mapNotNull { it.filename }
+ .filter { filename ->
+ !downloadPath.resolve(filename).exists()
+ }
+ if (missingFiles.isNotEmpty()) {
+ val errorMessage = "Missing required files: ${missingFiles.joinToString(", ")}"
+ logger.error { errorMessage }
+ throw LspException(errorMessage, LspException.ErrorCode.DOWNLOAD_FAILED)
+ }
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt
new file mode 100644
index 00000000000..26cd96032df
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt
@@ -0,0 +1,111 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
+
+import com.intellij.util.text.SemVer
+import org.assertj.core.util.VisibleForTesting
+import software.aws.toolkits.core.utils.error
+import software.aws.toolkits.core.utils.getLogger
+import software.aws.toolkits.core.utils.info
+import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
+
+class ArtifactManager {
+
+ data class SupportedManifestVersionRange(
+ val startVersion: SemVer,
+ val endVersion: SemVer,
+ )
+ data class LSPVersions(
+ val deListedVersions: List,
+ val inRangeVersions: List,
+ )
+
+ private val manifestFetcher: ManifestFetcher
+ private val artifactHelper: ArtifactHelper
+ private val manifestVersionRanges: SupportedManifestVersionRange
+
+ // Primary constructor with config
+ constructor(
+ manifestFetcher: ManifestFetcher = ManifestFetcher(),
+ artifactFetcher: ArtifactHelper = ArtifactHelper(),
+ manifestRange: SupportedManifestVersionRange?,
+ ) {
+ manifestVersionRanges = manifestRange ?: DEFAULT_VERSION_RANGE
+ this.manifestFetcher = manifestFetcher
+ this.artifactHelper = artifactFetcher
+ }
+
+ // Secondary constructor with no parameters
+ constructor() : this(ManifestFetcher(), ArtifactHelper(), null)
+
+ companion object {
+ private val DEFAULT_VERSION_RANGE = SupportedManifestVersionRange(
+ startVersion = SemVer("3.0.0", 3, 0, 0),
+ endVersion = SemVer("4.0.0", 4, 0, 0)
+ )
+ private val logger = getLogger()
+ }
+
+ fun fetchArtifact() {
+ val manifest = manifestFetcher.fetch() ?: throw LspException(
+ "Language Support is not available, as manifest is missing.",
+ LspException.ErrorCode.MANIFEST_FETCH_FAILED
+ )
+ val lspVersions = getLSPVersionsFromManifestWithSpecifiedRange(manifest)
+
+ this.artifactHelper.removeDelistedVersions(lspVersions.deListedVersions)
+
+ if (lspVersions.inRangeVersions.isEmpty()) {
+ // No versions are found which are in the given range.
+ throw LspException("Language server versions not found in manifest.", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION)
+ }
+
+ // If there is an LSP Manifest with the same version
+ val target = getTargetFromLspManifest(lspVersions.inRangeVersions)
+
+ // Get Local LSP files and check if we can re-use existing LSP Artifacts
+ if (!this.artifactHelper.getExistingLspArtifacts(lspVersions.inRangeVersions, target)) {
+ this.artifactHelper.tryDownloadLspArtifacts(lspVersions.inRangeVersions, target)
+ }
+
+ this.artifactHelper.deleteOlderLspArtifacts(manifestVersionRanges)
+ }
+
+ @VisibleForTesting
+ internal fun getLSPVersionsFromManifestWithSpecifiedRange(manifest: ManifestManager.Manifest): LSPVersions {
+ if (manifest.versions.isNullOrEmpty()) return LSPVersions(emptyList(), emptyList())
+
+ val (deListed, inRange) = manifest.versions.mapNotNull { version ->
+ version.serverVersion?.let { serverVersion ->
+ SemVer.parseFromText(serverVersion)?.let { semVer ->
+ when {
+ version.isDelisted != false -> Pair(version, true) // Is deListed
+ semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion -> Pair(version, false) // Is in range
+ else -> null
+ }
+ }
+ }
+ }.partition { it.second }
+
+ return LSPVersions(
+ deListedVersions = deListed.map { it.first },
+ inRangeVersions = inRange.map { it.first }.sortedByDescending { (_, semVer) -> semVer }
+ )
+ }
+
+ private fun getTargetFromLspManifest(versions: List): ManifestManager.VersionTarget {
+ val currentOS = getCurrentOS()
+ val currentArchitecture = getCurrentArchitecture()
+
+ val currentTarget = versions.first().targets?.find { target ->
+ target.platform == currentOS && target.arch == currentArchitecture
+ }
+ if (currentTarget == null) {
+ logger.error { "Failed to obtain target for $currentOS and $currentArchitecture" }
+ throw LspException("Target not found in the current Version: ${versions.first().serverVersion}", LspException.ErrorCode.TARGET_NOT_FOUND)
+ }
+ logger.info { "Target found in the current Version: ${versions.first().serverVersion}" }
+ return currentTarget
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt
new file mode 100644
index 00000000000..6c28a239002
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt
@@ -0,0 +1,20 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
+
+class LspException(message: String, private val errorCode: ErrorCode, cause: Throwable? = null) : Exception(message, cause) {
+
+ enum class ErrorCode {
+ MANIFEST_FETCH_FAILED,
+ DOWNLOAD_FAILED,
+ HASH_MISMATCH,
+ TARGET_NOT_FOUND,
+ NO_COMPATIBLE_LSP_VERSION,
+ }
+
+ override fun toString(): String = buildString {
+ append("LSP Error [$errorCode]: $message")
+ cause?.let { append(", Cause: ${it.message}") }
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt
new file mode 100644
index 00000000000..6c5510af664
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt
@@ -0,0 +1,68 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
+
+import com.intellij.openapi.util.SystemInfo
+import com.intellij.openapi.util.text.StringUtil
+import com.intellij.util.io.DigestUtil
+import com.intellij.util.system.CpuArch
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.Paths
+import java.nio.file.StandardCopyOption
+import java.security.MessageDigest
+import kotlin.io.path.isDirectory
+import kotlin.io.path.listDirectoryEntries
+
+fun getToolkitsCommonCacheRoot(): Path = when {
+ SystemInfo.isWindows -> {
+ Paths.get(System.getenv("LOCALAPPDATA"))
+ }
+ SystemInfo.isMac -> {
+ Paths.get(System.getProperty("user.home"), "Library", "Caches")
+ }
+ else -> {
+ Paths.get(System.getProperty("user.home"), ".cache")
+ }
+}
+
+fun getCurrentOS(): String = when {
+ SystemInfo.isWindows -> "windows"
+ SystemInfo.isMac -> "darwin"
+ else -> "linux"
+}
+
+fun getCurrentArchitecture() = when (CpuArch.CURRENT) {
+ CpuArch.X86_64 -> "x64"
+ CpuArch.ARM64 -> "arm64"
+ else -> "unknown"
+}
+
+fun generateMD5Hash(filePath: Path): String {
+ val messageDigest = DigestUtil.md5()
+ DigestUtil.updateContentHash(messageDigest, filePath)
+ return StringUtil.toHexString(messageDigest.digest())
+}
+
+fun generateSHA384Hash(filePath: Path): String {
+ val messageDigest = MessageDigest.getInstance("SHA-384")
+ DigestUtil.updateContentHash(messageDigest, filePath)
+ return StringUtil.toHexString(messageDigest.digest())
+}
+
+fun getSubFolders(basePath: Path): List = try {
+ basePath.listDirectoryEntries()
+ .filter { it.isDirectory() }
+} catch (e: Exception) {
+ emptyList()
+}
+
+fun moveFilesFromSourceToDestination(sourceDir: Path, targetDir: Path) {
+ try {
+ Files.createDirectories(targetDir.parent)
+ Files.move(sourceDir, targetDir, StandardCopyOption.REPLACE_EXISTING)
+ } catch (e: Exception) {
+ throw IllegalStateException("Failed to move files from $sourceDir to $targetDir", e)
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt
new file mode 100644
index 00000000000..0b7d41f8286
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt
@@ -0,0 +1,111 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
+
+import org.assertj.core.util.VisibleForTesting
+import software.aws.toolkits.core.utils.deleteIfExists
+import software.aws.toolkits.core.utils.error
+import software.aws.toolkits.core.utils.exists
+import software.aws.toolkits.core.utils.getLogger
+import software.aws.toolkits.core.utils.info
+import software.aws.toolkits.core.utils.readText
+import software.aws.toolkits.jetbrains.core.getETagFromUrl
+import software.aws.toolkits.jetbrains.core.getTextFromUrl
+import software.aws.toolkits.jetbrains.core.saveFileFromUrl
+import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
+import java.nio.file.Path
+
+class ManifestFetcher(
+ private val lspManifestUrl: String = DEFAULT_MANIFEST_URL,
+ private val manifestManager: ManifestManager = ManifestManager(),
+ private val lspManifestFilePath: Path = DEFAULT_MANIFEST_PATH,
+) {
+ companion object {
+ private val logger = getLogger()
+
+ private const val DEFAULT_MANIFEST_URL =
+ "https://aws-toolkit-language-servers.amazonaws.com/codewhisperer/0/manifest.json"
+
+ private val DEFAULT_MANIFEST_PATH: Path = getToolkitsCommonCacheRoot()
+ .resolve("aws")
+ .resolve("toolkits")
+ .resolve("language-servers")
+ .resolve("jetbrains-lsp-manifest.json")
+ }
+
+ /**
+ * Method which will be used to fetch latest manifest.
+ * */
+ fun fetch(): ManifestManager.Manifest? {
+ val localManifest = fetchManifestFromLocal()
+ if (localManifest != null) {
+ return localManifest
+ }
+ return fetchManifestFromRemote()
+ }
+
+ @VisibleForTesting
+ internal fun fetchManifestFromRemote(): ManifestManager.Manifest? {
+ val manifest: ManifestManager.Manifest?
+ try {
+ val manifestString = getTextFromUrl(lspManifestUrl)
+ manifest = manifestManager.readManifestFile(manifestString) ?: return null
+ } catch (e: Exception) {
+ logger.error(e) { "error fetching lsp manifest from remote URL ${e.message}" }
+ return null
+ }
+ if (manifest.isManifestDeprecated == true) {
+ logger.info { "Manifest is deprecated" }
+ return null
+ }
+ updateManifestCache()
+ logger.info { "Using manifest found from remote URL" }
+ return manifest
+ }
+
+ private fun updateManifestCache() {
+ try {
+ saveFileFromUrl(lspManifestUrl, lspManifestFilePath)
+ } catch (e: Exception) {
+ logger.error(e) { "error occurred while saving lsp manifest to local cache ${e.message}" }
+ }
+ }
+
+ @VisibleForTesting
+ internal fun fetchManifestFromLocal(): ManifestManager.Manifest? {
+ val localETag = getManifestETagFromLocal()
+ val remoteETag = getManifestETagFromUrl()
+ // If local and remote have same ETag, we can re-use the manifest file from local to fetch artifacts.
+ // If remote manifest is null or system is offline, re-use localManifest
+ if ((localETag != null && remoteETag != null && localETag == remoteETag) or (localETag != null && remoteETag == null)) {
+ try {
+ val manifestContent = lspManifestFilePath.readText()
+ val manifest = manifestManager.readManifestFile(manifestContent)
+ if (manifest != null) return manifest
+ lspManifestFilePath.deleteIfExists() // delete manifest if it fails to de-serialize
+ } catch (e: Exception) {
+ logger.error(e) { "error reading lsp manifest file from local ${e.message}" }
+ return null
+ }
+ }
+ return null
+ }
+
+ private fun getManifestETagFromLocal(): String? {
+ if (lspManifestFilePath.exists()) {
+ return generateMD5Hash(lspManifestFilePath)
+ }
+ return null
+ }
+
+ private fun getManifestETagFromUrl(): String? {
+ try {
+ val actualETag = getETagFromUrl(lspManifestUrl)
+ return actualETag.trim('"')
+ } catch (e: Exception) {
+ logger.error(e) { "error fetching ETag of lsp manifest from url." }
+ }
+ return null
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt
index 4693db1004f..7ac2530eb91 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/manifest/ManifestManager.kt
@@ -3,8 +3,8 @@
package software.aws.toolkits.jetbrains.services.amazonq.project.manifest
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty
+import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.intellij.openapi.util.SystemInfo
@@ -18,10 +18,9 @@ class ManifestManager {
val currentVersion = "0.1.32"
val currentOs = getOs()
private val arch = CpuArch.CURRENT
- private val mapper = jacksonObjectMapper()
+ private val mapper = jacksonObjectMapper().apply { configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) }
data class TargetContent(
- @JsonIgnoreProperties(ignoreUnknown = true)
@JsonProperty("filename")
val filename: String? = null,
@JsonProperty("url")
@@ -33,7 +32,6 @@ class ManifestManager {
)
data class VersionTarget(
- @JsonIgnoreProperties(ignoreUnknown = true)
@JsonProperty("platform")
val platform: String? = null,
@JsonProperty("arch")
@@ -41,8 +39,8 @@ class ManifestManager {
@JsonProperty("contents")
val contents: List? = emptyList(),
)
+
data class Version(
- @JsonIgnoreProperties(ignoreUnknown = true)
@JsonProperty("serverVersion")
val serverVersion: String? = null,
@JsonProperty("isDelisted")
@@ -52,7 +50,6 @@ class ManifestManager {
)
data class Manifest(
- @JsonIgnoreProperties(ignoreUnknown = true)
@JsonProperty("manifestSchemaVersion")
val manifestSchemaVersion: String? = null,
@JsonProperty("artifactId")
@@ -67,7 +64,7 @@ class ManifestManager {
fun getManifest(): Manifest? = fetchFromRemoteAndSave()
- private fun readManifestFile(content: String): Manifest? {
+ fun readManifestFile(content: String): Manifest? {
try {
return mapper.readValue(content)
} catch (e: Exception) {
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt
new file mode 100644
index 00000000000..62e17089eef
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt
@@ -0,0 +1,100 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
+
+import io.mockk.every
+import io.mockk.mockkStatic
+import org.assertj.core.api.Assertions.assertThat
+import org.jetbrains.annotations.TestOnly
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.mockito.kotlin.atLeastOnce
+import org.mockito.kotlin.never
+import org.mockito.kotlin.reset
+import org.mockito.kotlin.spy
+import org.mockito.kotlin.verify
+import org.mockito.kotlin.whenever
+import software.aws.toolkits.jetbrains.core.getTextFromUrl
+import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
+
+@TestOnly
+class ManifestFetcherTest {
+
+ private lateinit var manifestFetcher: ManifestFetcher
+ private lateinit var manifest: ManifestManager.Manifest
+ private lateinit var manifestManager: ManifestManager
+
+ @BeforeEach
+ fun setup() {
+ manifestFetcher = spy(ManifestFetcher())
+ manifestManager = spy(ManifestManager())
+ manifest = ManifestManager.Manifest()
+ }
+
+ @Test
+ fun `should return null when both local and remote manifests are null`() {
+ whenever(manifestFetcher.fetchManifestFromLocal()).thenReturn(null)
+ whenever(manifestFetcher.fetchManifestFromRemote()).thenReturn(null)
+
+ assertThat(manifestFetcher.fetch()).isNull()
+ }
+
+ @Test
+ fun `should return valid result from local should not execute remote method`() {
+ reset(manifestFetcher)
+ whenever(manifestFetcher.fetchManifestFromLocal()).thenReturn(manifest)
+
+ assertThat(manifestFetcher.fetch()).isNotNull().isEqualTo(manifest)
+ verify(manifestFetcher, atLeastOnce()).fetchManifestFromLocal()
+ verify(manifestFetcher, never()).fetchManifestFromRemote()
+ }
+
+ @Test
+ fun `should return valid result from remote`() {
+ whenever(manifestFetcher.fetchManifestFromLocal()).thenReturn(null)
+ whenever(manifestFetcher.fetchManifestFromRemote()).thenReturn(manifest)
+
+ assertThat(manifestFetcher.fetch()).isNotNull().isEqualTo(manifest)
+ verify(manifestFetcher, atLeastOnce()).fetchManifestFromLocal()
+ verify(manifestFetcher, atLeastOnce()).fetchManifestFromRemote()
+ }
+
+ @Test
+ fun `fetchManifestFromRemote should return null due to invalid manifestString`() {
+ mockkStatic("software.aws.toolkits.jetbrains.core.HttpUtilsKt")
+ every { getTextFromUrl(any()) } returns "ManifestContent"
+
+ whenever(manifestManager.readManifestFile("")).thenReturn(null)
+
+ assertThat(manifestFetcher.fetchManifestFromRemote()).isNull()
+ }
+
+ @Test
+ fun `fetchManifestFromRemote should return manifest and update manifest`() {
+ val validManifest = ManifestManager.Manifest(manifestSchemaVersion = "1.0")
+ mockkStatic("software.aws.toolkits.jetbrains.core.HttpUtilsKt")
+
+ every { getTextFromUrl(any()) } returns "{ \"manifestSchemaVersion\": \"1.0\" }"
+
+ val result = manifestFetcher.fetchManifestFromRemote()
+ assertThat(result).isNotNull().isEqualTo(validManifest)
+ }
+
+ @Test
+ fun `fetchManifestFromRemote should return null if manifest is deprecated`() {
+ mockkStatic("software.aws.toolkits.jetbrains.core.HttpUtilsKt")
+ every { getTextFromUrl(any()) } returns "ManifestContent"
+
+ val deprecatedManifest = ManifestManager.Manifest(isManifestDeprecated = true)
+
+ whenever(manifestManager.readManifestFile("")).thenReturn(deprecatedManifest)
+
+ assertThat(manifestFetcher.fetchManifestFromRemote()).isNull()
+ }
+
+ @Test
+ fun `fetchManifestFromLocal should return null`() {
+ assertThat(manifestFetcher.fetchManifestFromLocal()).isNull()
+ }
+}
diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt
index af45361ac3d..1caf2e8e463 100644
--- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt
+++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/DefaultRemoteResourceResolverProvider.kt
@@ -4,7 +4,6 @@
package software.aws.toolkits.jetbrains.core
import com.intellij.openapi.application.PathManager
-import com.intellij.util.io.HttpRequests
import com.intellij.util.io.createDirectories
import software.aws.toolkits.core.utils.DefaultRemoteResourceResolver
import software.aws.toolkits.core.utils.UrlFetcher
@@ -41,11 +40,7 @@ class DefaultRemoteResourceResolverProvider : RemoteResourceResolverProvider {
}
override fun getETag(url: String): String =
- HttpRequests.head(url)
- .userAgent("AWS Toolkit for JetBrains")
- .connect { request ->
- request.connection.headerFields["ETag"]?.firstOrNull().orEmpty()
- }
+ getETagFromUrl(url)
}
}
}
diff --git a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt
index 27958005fe4..8456be0f2bc 100644
--- a/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt
+++ b/plugins/core/jetbrains-community/src/software/aws/toolkits/jetbrains/core/HttpUtils.kt
@@ -24,3 +24,10 @@ fun writeJsonToUrl(url: String, jsonString: String, indicator: ProgressIndicator
request.write(jsonString)
request.readString(indicator)
}
+
+fun getETagFromUrl(url: String): String =
+ HttpRequests.head(url)
+ .userAgent(AwsClientManager.getUserAgent())
+ .connect { request ->
+ request.connection.headerFields["ETag"]?.firstOrNull().orEmpty()
+ }
From 35b04243ed18e3a098b2ac6db7f9b6580c1deb77 Mon Sep 17 00:00:00 2001
From: Sam Stewart
Date: Wed, 26 Feb 2025 14:57:36 -0800
Subject: [PATCH 011/117] feat(amazonq): Implement aws/credentials/token
messages (#5410)
* implement aws/credentials/token messages
* detekt
* feedback
---
.../amazonq/lsp/AmazonQLanguageServer.kt | 4 +
.../services/amazonq/lsp/AmazonQLspService.kt | 10 +-
.../lsp/auth/AuthCredentialsService.kt | 12 +++
.../lsp/auth/DefaultAuthCredentialsService.kt | 51 ++++++++++
.../auth/DefaultAuthCredentialsServiceTest.kt | 93 +++++++++++++++++++
5 files changed, 164 insertions(+), 6 deletions(-)
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/AuthCredentialsService.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt
index 6a40867a7e0..e8281f6b56a 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt
@@ -4,6 +4,7 @@
package software.aws.toolkits.jetbrains.services.amazonq.lsp
import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage
+import org.eclipse.lsp4j.jsonrpc.services.JsonNotification
import org.eclipse.lsp4j.jsonrpc.services.JsonRequest
import org.eclipse.lsp4j.services.LanguageServer
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.UpdateCredentialsPayload
@@ -16,4 +17,7 @@ import java.util.concurrent.CompletableFuture
interface AmazonQLanguageServer : LanguageServer {
@JsonRequest("aws/credentials/token/update")
fun updateTokenCredentials(payload: UpdateCredentialsPayload): CompletableFuture
+
+ @JsonNotification("aws/credentials/token/delete")
+ fun deleteTokenCredentials(): CompletableFuture
}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
index 28445578c67..49141741de0 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
@@ -154,28 +154,26 @@ class AmazonQLspService(private val project: Project, private val cs: CoroutineS
instance = start()
}
- suspend fun execute(runnable: suspend (AmazonQLanguageServer) -> Unit) {
+ suspend fun execute(runnable: suspend (AmazonQLanguageServer) -> T): T {
val lsp = withTimeout(10.seconds) {
val holder = mutex.withLock { instance }.await()
holder.initializer.join()
holder.languageServer
}
-
- runnable(lsp)
+ return runnable(lsp)
}
- fun executeSync(runnable: suspend (AmazonQLanguageServer) -> Unit) {
+ fun executeSync(runnable: suspend (AmazonQLanguageServer) -> T): T =
runBlocking(cs.coroutineContext) {
execute(runnable)
}
- }
companion object {
private val LOG = getLogger()
fun getInstance(project: Project) = project.service()
- fun executeIfRunning(project: Project, runnable: (AmazonQLanguageServer) -> Unit) =
+ fun executeIfRunning(project: Project, runnable: (AmazonQLanguageServer) -> T): T? =
project.serviceIfCreated()?.executeSync(runnable)
}
}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/AuthCredentialsService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/AuthCredentialsService.kt
new file mode 100644
index 00000000000..a38c8da4bbc
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/AuthCredentialsService.kt
@@ -0,0 +1,12 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.auth
+
+import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage
+import java.util.concurrent.CompletableFuture
+
+interface AuthCredentialsService {
+ fun updateTokenCredentials(accessToken: String, encrypted: Boolean): CompletableFuture
+ fun deleteTokenCredentials(): CompletableFuture
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt
new file mode 100644
index 00000000000..6fdc3bf1ef8
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt
@@ -0,0 +1,51 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.auth
+
+import com.intellij.openapi.project.Project
+import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.BearerCredentials
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.UpdateCredentialsPayload
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.UpdateCredentialsPayloadData
+import java.util.concurrent.CompletableFuture
+
+class DefaultAuthCredentialsService(
+ private val project: Project,
+ private val encryptionManager: JwtEncryptionManager,
+) : AuthCredentialsService {
+
+ override fun updateTokenCredentials(accessToken: String, encrypted: Boolean): CompletableFuture {
+ val token = if (encrypted) {
+ encryptionManager.decrypt(accessToken)
+ } else {
+ accessToken
+ }
+
+ val payload = createUpdateCredentialsPayload(token)
+
+ return AmazonQLspService.executeIfRunning(project) { server ->
+ server.updateTokenCredentials(payload)
+ } ?: (CompletableFuture.failedFuture(IllegalStateException("LSP Server not running")))
+ }
+
+ override fun deleteTokenCredentials(): CompletableFuture =
+ CompletableFuture().also { completableFuture ->
+ AmazonQLspService.executeIfRunning(project) { server ->
+ server.deleteTokenCredentials()
+ completableFuture.complete(null)
+ } ?: completableFuture.completeExceptionally(IllegalStateException("LSP Server not running"))
+ }
+
+ private fun createUpdateCredentialsPayload(token: String): UpdateCredentialsPayload =
+ UpdateCredentialsPayload(
+ data = encryptionManager.encrypt(
+ UpdateCredentialsPayloadData(
+ BearerCredentials(token)
+ )
+ ),
+ encrypted = true
+ )
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt
new file mode 100644
index 00000000000..2c4c003ba8c
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt
@@ -0,0 +1,93 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.auth
+
+import com.intellij.openapi.components.serviceIfCreated
+import com.intellij.openapi.project.Project
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage
+import org.junit.Before
+import org.junit.Test
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLanguageServer
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager
+import java.util.concurrent.CompletableFuture
+
+class DefaultAuthCredentialsServiceTest {
+ private lateinit var project: Project
+ private lateinit var mockLanguageServer: AmazonQLanguageServer
+ private lateinit var mockEncryptionManager: JwtEncryptionManager
+ private lateinit var sut: DefaultAuthCredentialsService
+
+ @Before
+ fun setUp() {
+ project = mockk()
+ mockLanguageServer = mockk()
+ mockEncryptionManager = mockk()
+ every { mockEncryptionManager.encrypt(any()) } returns "mock-encrypted-data"
+
+ // Mock the service methods on Project
+ val mockLspService = mockk()
+ every { project.getService(AmazonQLspService::class.java) } returns mockLspService
+ every { project.serviceIfCreated() } returns mockLspService
+
+ // Mock the LSP service's executeSync method as a suspend function
+ every {
+ mockLspService.executeSync>(any())
+ } coAnswers {
+ val func = firstArg CompletableFuture>()
+ func.invoke(mockLanguageServer)
+ }
+
+ sut = DefaultAuthCredentialsService(project, this.mockEncryptionManager)
+ }
+
+ @Test
+ fun `test updateTokenCredentials unencrypted success`() {
+ val token = "unencryptedToken"
+ val isEncrypted = false
+
+ every {
+ mockLanguageServer.updateTokenCredentials(any())
+ } returns CompletableFuture.completedFuture(ResponseMessage())
+
+ sut.updateTokenCredentials(token, isEncrypted)
+
+ verify(exactly = 0) {
+ mockEncryptionManager.decrypt(any())
+ }
+ verify(exactly = 1) {
+ mockLanguageServer.updateTokenCredentials(any())
+ }
+ }
+
+ @Test
+ fun `test updateTokenCredentials encrypted success`() {
+ val encryptedToken = "encryptedToken"
+ val decryptedToken = "decryptedToken"
+ val isEncrypted = true
+
+ every { mockEncryptionManager.decrypt(encryptedToken) } returns decryptedToken
+ every { mockEncryptionManager.encrypt(any()) } returns "mock-encrypted-data"
+ every {
+ mockLanguageServer.updateTokenCredentials(any())
+ } returns CompletableFuture.completedFuture(ResponseMessage())
+
+ sut.updateTokenCredentials(encryptedToken, isEncrypted)
+
+ verify(exactly = 1) { mockEncryptionManager.decrypt(encryptedToken) }
+ verify(exactly = 1) { mockLanguageServer.updateTokenCredentials(any()) }
+ }
+
+ @Test
+ fun `test deleteTokenCredentials success`() {
+ every { mockLanguageServer.deleteTokenCredentials() } returns CompletableFuture.completedFuture(Unit)
+
+ sut.deleteTokenCredentials()
+
+ verify(exactly = 1) { mockLanguageServer.deleteTokenCredentials() }
+ }
+}
From bd4dd634a997777300223204a7f3f170394f779c Mon Sep 17 00:00:00 2001
From: Lokesh
Date: Thu, 27 Feb 2025 13:05:22 -0800
Subject: [PATCH 012/117] feat(amazonq): Extract ZIP File and Unit Test Cases
(#5416)
* Adding unit test cases
* Added extractZipFile functionality
---
.../amazonq/lsp/artifacts/ArtifactHelper.kt | 46 ++--
.../amazonq/lsp/artifacts/ArtifactManager.kt | 29 +--
.../amazonq/lsp/artifacts/LspException.kt | 1 +
.../amazonq/lsp/artifacts/LspUtils.kt | 28 +++
.../amazonq/lsp/artifacts/ManifestFetcher.kt | 2 +-
.../services/amazonq/project/EncoderServer.kt | 27 +-
.../lsp/artifacts/ArtifactHelperTest.kt | 237 ++++++++++++++++++
.../lsp/artifacts/ArtifactManagerTest.kt | 134 ++++++++++
8 files changed, 443 insertions(+), 61 deletions(-)
create mode 100644 plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt
index 00cc5923195..e22bea572ad 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt
@@ -5,6 +5,7 @@ package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
import com.intellij.util.io.createDirectories
import com.intellij.util.text.SemVer
+import org.jetbrains.annotations.VisibleForTesting
import software.aws.toolkits.core.utils.deleteIfExists
import software.aws.toolkits.core.utils.error
import software.aws.toolkits.core.utils.exists
@@ -45,9 +46,23 @@ class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH,
}
fun deleteOlderLspArtifacts(manifestVersionRanges: ArtifactManager.SupportedManifestVersionRange) {
+ val validVersions = getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges)
+
+ // Keep the latest 2 versions, delete others
+ validVersions.drop(2).forEach { (folder, _) ->
+ try {
+ folder.toFile().deleteRecursively()
+ logger.info { "Deleted older LSP artifact: ${folder.fileName}" }
+ } catch (e: Exception) {
+ logger.error(e) { "Failed to delete older LSP artifact: ${folder.fileName}" }
+ }
+ }
+ }
+
+ fun getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges: ArtifactManager.SupportedManifestVersionRange): List> {
val localFolders = getSubFolders(lspArtifactsPath)
- val validVersions = localFolders
+ return localFolders
.mapNotNull { localFolder ->
SemVer.parseFromText(localFolder.fileName.toString())?.let { semVer ->
if (semVer in manifestVersionRanges.startVersion..manifestVersionRanges.endVersion) {
@@ -58,16 +73,6 @@ class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH,
}
}
.sortedByDescending { (_, semVer) -> semVer }
-
- // Keep the latest 2 versions, delete others
- validVersions.drop(2).forEach { (folder, _) ->
- try {
- folder.toFile().deleteRecursively()
- logger.info { "Deleted older LSP artifact: ${folder.fileName}" }
- } catch (e: Exception) {
- logger.error(e) { "Failed to delete older LSP artifact: ${folder.fileName}" }
- }
- }
}
fun getExistingLspArtifacts(versions: List, target: ManifestManager.VersionTarget?): Boolean {
@@ -103,23 +108,27 @@ class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH,
logger.info { "Attempt ${currentAttempt.get()} of $maxDownloadAttempts to download LSP artifacts" }
try {
- if (downloadLspArtifacts(temporaryDownloadPath, target)) {
+ if (downloadLspArtifacts(temporaryDownloadPath, target) && target != null && !target.contents.isNullOrEmpty()) {
moveFilesFromSourceToDestination(temporaryDownloadPath, downloadPath)
+ target.contents
+ .mapNotNull { it.filename }
+ .forEach { filename -> extractZipFile(downloadPath.resolve(filename), downloadPath) }
logger.info { "Successfully downloaded and moved LSP artifacts to $downloadPath" }
return
}
} catch (e: Exception) {
logger.error(e) { "Failed to download/move LSP artifacts on attempt ${currentAttempt.get()}" }
temporaryDownloadPath.toFile().deleteRecursively()
-
- if (currentAttempt.get() >= maxDownloadAttempts) {
- throw LspException("Failed to download LSP artifacts after $maxDownloadAttempts attempts", LspException.ErrorCode.DOWNLOAD_FAILED)
- }
+ downloadPath.toFile().deleteRecursively()
}
}
+ if (currentAttempt.get() >= maxDownloadAttempts) {
+ throw LspException("Failed to download LSP artifacts after $maxDownloadAttempts attempts", LspException.ErrorCode.DOWNLOAD_FAILED)
+ }
}
- private fun downloadLspArtifacts(downloadPath: Path, target: ManifestManager.VersionTarget?): Boolean {
+ @VisibleForTesting
+ internal fun downloadLspArtifacts(downloadPath: Path, target: ManifestManager.VersionTarget?): Boolean {
if (target == null || target.contents.isNullOrEmpty()) {
logger.warn { "No target contents available for download" }
return false
@@ -166,7 +175,8 @@ class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH,
}
}
- private fun validateFileHash(filePath: Path, expectedHash: String?): Boolean {
+ @VisibleForTesting
+ internal fun validateFileHash(filePath: Path, expectedHash: String?): Boolean {
if (expectedHash == null) return false
val contentHash = generateSHA384Hash(filePath)
return "sha384:$contentHash" == expectedHash
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt
index 26cd96032df..0286bd89939 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt
@@ -4,13 +4,17 @@
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
import com.intellij.util.text.SemVer
-import org.assertj.core.util.VisibleForTesting
+import org.jetbrains.annotations.VisibleForTesting
import software.aws.toolkits.core.utils.error
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.info
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
-class ArtifactManager {
+class ArtifactManager(
+ private val manifestFetcher: ManifestFetcher = ManifestFetcher(),
+ private val artifactHelper: ArtifactHelper = ArtifactHelper(),
+ manifestRange: SupportedManifestVersionRange?,
+) {
data class SupportedManifestVersionRange(
val startVersion: SemVer,
@@ -21,20 +25,7 @@ class ArtifactManager {
val inRangeVersions: List,
)
- private val manifestFetcher: ManifestFetcher
- private val artifactHelper: ArtifactHelper
- private val manifestVersionRanges: SupportedManifestVersionRange
-
- // Primary constructor with config
- constructor(
- manifestFetcher: ManifestFetcher = ManifestFetcher(),
- artifactFetcher: ArtifactHelper = ArtifactHelper(),
- manifestRange: SupportedManifestVersionRange?,
- ) {
- manifestVersionRanges = manifestRange ?: DEFAULT_VERSION_RANGE
- this.manifestFetcher = manifestFetcher
- this.artifactHelper = artifactFetcher
- }
+ private val manifestVersionRanges: SupportedManifestVersionRange = manifestRange ?: DEFAULT_VERSION_RANGE
// Secondary constructor with no parameters
constructor() : this(ManifestFetcher(), ArtifactHelper(), null)
@@ -57,7 +48,11 @@ class ArtifactManager {
this.artifactHelper.removeDelistedVersions(lspVersions.deListedVersions)
if (lspVersions.inRangeVersions.isEmpty()) {
- // No versions are found which are in the given range.
+ // No versions are found which are in the given range. Fallback to local lsp artifacts.
+ val localLspArtifacts = this.artifactHelper.getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges)
+ if (localLspArtifacts.isNotEmpty()) {
+ return
+ }
throw LspException("Language server versions not found in manifest.", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION)
}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt
index 6c28a239002..110acd14b5d 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspException.kt
@@ -11,6 +11,7 @@ class LspException(message: String, private val errorCode: ErrorCode, cause: Thr
HASH_MISMATCH,
TARGET_NOT_FOUND,
NO_COMPATIBLE_LSP_VERSION,
+ UNZIP_FAILED,
}
override fun toString(): String = buildString {
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt
index 6c5510af664..0c361fe0154 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/LspUtils.kt
@@ -7,11 +7,16 @@ import com.intellij.openapi.util.SystemInfo
import com.intellij.openapi.util.text.StringUtil
import com.intellij.util.io.DigestUtil
import com.intellij.util.system.CpuArch
+import software.aws.toolkits.core.utils.createParentDirectories
+import software.aws.toolkits.core.utils.exists
+import java.io.FileNotFoundException
+import java.io.FileOutputStream
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.nio.file.StandardCopyOption
import java.security.MessageDigest
+import java.util.zip.ZipFile
import kotlin.io.path.isDirectory
import kotlin.io.path.listDirectoryEntries
@@ -66,3 +71,26 @@ fun moveFilesFromSourceToDestination(sourceDir: Path, targetDir: Path) {
throw IllegalStateException("Failed to move files from $sourceDir to $targetDir", e)
}
}
+
+fun extractZipFile(zipFilePath: Path, destDir: Path) {
+ if (!zipFilePath.exists()) {
+ throw FileNotFoundException("Zip file not found: $zipFilePath")
+ }
+
+ try {
+ ZipFile(zipFilePath.toFile()).use { zipFile ->
+ zipFile.entries()
+ .asSequence()
+ .filterNot { it.isDirectory }
+ .map { zipEntry ->
+ val destPath = destDir.resolve(zipEntry.name)
+ destPath.createParentDirectories()
+ FileOutputStream(destPath.toFile()).use { targetFile ->
+ zipFile.getInputStream(zipEntry).copyTo(targetFile)
+ }
+ }.toList()
+ }
+ } catch (e: Exception) {
+ throw LspException("Failed to extract zip file: ${e.message}", LspException.ErrorCode.UNZIP_FAILED, cause = e)
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt
index 0b7d41f8286..92d0f8d9e98 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt
@@ -3,7 +3,7 @@
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
-import org.assertj.core.util.VisibleForTesting
+import org.jetbrains.annotations.VisibleForTesting
import software.aws.toolkits.core.utils.deleteIfExists
import software.aws.toolkits.core.utils.error
import software.aws.toolkits.core.utils.exists
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/EncoderServer.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/EncoderServer.kt
index 5b61aedf94d..2da14c2dd50 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/EncoderServer.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/project/EncoderServer.kt
@@ -21,15 +21,13 @@ import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import org.apache.commons.codec.digest.DigestUtils
import software.amazon.awssdk.utils.UserHomeDirectoryUtils
-import software.aws.toolkits.core.utils.createParentDirectories
-import software.aws.toolkits.core.utils.exists
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.info
import software.aws.toolkits.core.utils.tryDirOp
import software.aws.toolkits.core.utils.warn
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.extractZipFile
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings
-import java.io.FileOutputStream
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Path
@@ -39,7 +37,6 @@ import java.security.Key
import java.security.SecureRandom
import java.util.Base64
import java.util.concurrent.atomic.AtomicInteger
-import java.util.zip.ZipFile
import javax.crypto.spec.SecretKeySpec
class EncoderServer(val project: Project) : Disposable {
@@ -183,7 +180,7 @@ class EncoderServer(val project: Project) : Disposable {
if (serverContent?.url != null) {
if (validateHash(serverContent.hashes?.first(), HttpRequests.request(serverContent.url).readBytes(null))) {
downloadFromRemote(serverContent.url, zipFilePath)
- unzipFile(zipFilePath, cachePath)
+ extractZipFile(zipFilePath, cachePath)
}
}
} catch (e: Exception) {
@@ -231,26 +228,6 @@ class EncoderServer(val project: Project) : Disposable {
Files.setPosixFilePermissions(filePath, permissions)
}
- private fun unzipFile(zipFilePath: Path, destDir: Path) {
- if (!zipFilePath.exists()) return
- try {
- val zipFile = ZipFile(zipFilePath.toFile())
- zipFile.use { file ->
- file.entries().asSequence()
- .filterNot { it.isDirectory }
- .map { zipEntry ->
- val destPath = destDir.resolve(zipEntry.name)
- destPath.createParentDirectories()
- FileOutputStream(destPath.toFile()).use { targetFile ->
- zipFile.getInputStream(zipEntry).copyTo(targetFile)
- }
- }.toList()
- }
- } catch (e: Exception) {
- logger.warn { "error while unzipping project context artifact: ${e.message}" }
- }
- }
-
private fun downloadFromRemote(url: String, path: Path) {
try {
HttpRequests.request(url).saveToFile(path, null)
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt
new file mode 100644
index 00000000000..8357fb78b82
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt
@@ -0,0 +1,237 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
+
+import com.intellij.util.io.createDirectories
+import com.intellij.util.text.SemVer
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import org.assertj.core.api.Assertions.assertThat
+import org.assertj.core.api.Assertions.assertThatThrownBy
+import org.jetbrains.annotations.TestOnly
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+import org.mockito.kotlin.mock
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager.SupportedManifestVersionRange
+import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
+import java.nio.file.Path
+
+@TestOnly
+class ArtifactHelperTest {
+ @TempDir
+ lateinit var tempDir: Path
+
+ private lateinit var artifactHelper: ArtifactHelper
+ private lateinit var manifestVersionRanges: SupportedManifestVersionRange
+ private lateinit var mockManifestManager: ManifestManager
+ private lateinit var contents: List
+
+ @BeforeEach
+ fun setUp() {
+ artifactHelper = ArtifactHelper(tempDir, 3)
+ mockManifestManager = mock()
+ contents = listOf(
+ ManifestManager.TargetContent(
+ filename = "server.zip",
+ hashes = listOf("sha384:1234")
+ )
+ )
+ }
+
+ @Test
+ fun `removeDelistedVersions removes specified versions`() {
+ val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() }
+ val version2Dir = tempDir.resolve("2.0.0").apply { toFile().mkdirs() }
+
+ val delistedVersions = listOf(
+ ManifestManager.Version(serverVersion = "1.0.0")
+ )
+
+ artifactHelper.removeDelistedVersions(delistedVersions)
+
+ assertThat(version1Dir.toFile().exists()).isFalse()
+ assertThat(version2Dir.toFile().exists()).isTrue()
+ }
+
+ @Test
+ fun `deleteOlderLspArtifacts should not delete if there are only two version`() {
+ val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() }
+ val version2Dir = tempDir.resolve("1.0.1").apply { toFile().mkdirs() }
+
+ manifestVersionRanges = SupportedManifestVersionRange(
+ startVersion = SemVer("1.0.0", 1, 0, 0),
+ endVersion = SemVer("2.0.0", 2, 0, 0)
+ )
+
+ artifactHelper.deleteOlderLspArtifacts(manifestVersionRanges)
+
+ assertThat(version1Dir.toFile().exists()).isTrue()
+ assertThat(version2Dir.toFile().exists()).isTrue()
+ }
+
+ @Test
+ fun `deleteOlderLspArtifacts should delete if there are more than two versions`() {
+ val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() }
+ val version2Dir = tempDir.resolve("1.0.1").apply { toFile().mkdirs() }
+ val version3Dir = tempDir.resolve("1.0.2").apply { toFile().mkdirs() }
+
+ manifestVersionRanges = SupportedManifestVersionRange(
+ startVersion = SemVer("1.0.0", 1, 0, 0),
+ endVersion = SemVer("2.0.0", 2, 0, 0)
+ )
+
+ artifactHelper.deleteOlderLspArtifacts(manifestVersionRanges)
+
+ assertThat(version1Dir.toFile().exists()).isFalse()
+ assertThat(version2Dir.toFile().exists()).isTrue()
+ assertThat(version3Dir.toFile().exists()).isTrue()
+ }
+
+ @Test
+ fun `getAllLocalLspArtifactsWithinManifestRange should return matching folder path`() {
+ tempDir.resolve("1.0.0").createDirectories()
+ tempDir.resolve("1.0.1").createDirectories()
+ tempDir.resolve("1.0.2").createDirectories()
+ manifestVersionRanges = SupportedManifestVersionRange(
+ startVersion = SemVer("1.0.0", 1, 0, 0),
+ endVersion = SemVer("2.0.0", 2, 0, 0)
+ )
+
+ val actualResult = artifactHelper.getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges)
+ assertThat(actualResult).isNotNull()
+ assertThat(actualResult.size).isEqualTo(3)
+ assertThat(actualResult.first().first.fileName.toString()).isEqualTo("1.0.2")
+ }
+
+ @Test
+ fun `getExistingLspArtifacts should find all the artifacts`() {
+ val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() }
+
+ val serverZipPath = version1Dir.resolve("server.zip")
+ serverZipPath.parent.toFile().mkdirs()
+ serverZipPath.toFile().createNewFile()
+
+ val versions = listOf(
+ ManifestManager.Version(serverVersion = "1.0.0")
+ )
+
+ val target = ManifestManager.VersionTarget(contents = contents)
+
+ mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt")
+ every { generateSHA384Hash(any()) } returns "1234"
+
+ val result = artifactHelper.getExistingLspArtifacts(versions, target)
+
+ assertThat(result).isTrue()
+ assertThat(serverZipPath.toFile().exists()).isTrue()
+ version1Dir.toFile().deleteRecursively()
+ }
+
+ @Test
+ fun `getExistingLspArtifacts should return false due to hash mismatch`() {
+ val version1Dir = tempDir.resolve("1.0.0").apply { toFile().mkdirs() }
+
+ val serverZipPath = version1Dir.resolve("server.zip")
+ serverZipPath.parent.toFile().mkdirs()
+ serverZipPath.toFile().createNewFile()
+
+ val versions = listOf(
+ ManifestManager.Version(serverVersion = "1.0.0")
+ )
+
+ val target = ManifestManager.VersionTarget(contents = contents)
+
+ mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt")
+ every { generateSHA384Hash(any()) } returns "1235"
+
+ val result = artifactHelper.getExistingLspArtifacts(versions, target)
+
+ assertThat(result).isFalse()
+ assertThat(serverZipPath.toFile().exists()).isFalse()
+ }
+
+ @Test
+ fun `getExistingLspArtifacts should return false if versions are empty`() {
+ val versions = emptyList()
+ assertThat(artifactHelper.getExistingLspArtifacts(versions, null)).isFalse()
+ }
+
+ @Test
+ fun `getExistingLspArtifacts should return false if target does not have contents`() {
+ val versions = listOf(
+ ManifestManager.Version(serverVersion = "1.0.0")
+ )
+ assertThat(artifactHelper.getExistingLspArtifacts(versions, null)).isFalse()
+ }
+
+ @Test
+ fun `getExistingLspArtifacts should return false if Lsp path does not exist`() {
+ val versions = listOf(
+ ManifestManager.Version(serverVersion = "1.0.0")
+ )
+ assertThat(artifactHelper.getExistingLspArtifacts(versions, null)).isFalse()
+ }
+
+ @Test
+ fun `tryDownloadLspArtifacts should not download artifacts if target does not have contents`() {
+ val versions = listOf(ManifestManager.Version(serverVersion = "2.0.0"))
+ assertThatThrownBy {
+ artifactHelper.tryDownloadLspArtifacts(versions, null)
+ }
+ .isInstanceOf(LspException::class.java)
+ .hasFieldOrPropertyWithValue("errorCode", LspException.ErrorCode.DOWNLOAD_FAILED)
+ assertThat(tempDir.resolve("2.0.0").toFile().exists()).isFalse()
+ }
+
+ @Test
+ fun `tryDownloadLspArtifacts should throw error if failed to download`() {
+ val versions = listOf(ManifestManager.Version(serverVersion = "1.0.0"))
+
+ val spyArtifactHelper = spyk(artifactHelper)
+ every { spyArtifactHelper.downloadLspArtifacts(any(), any()) } returns false
+
+ assertThatThrownBy {
+ spyArtifactHelper.tryDownloadLspArtifacts(versions, null)
+ }
+ .isInstanceOf(LspException::class.java)
+ .hasFieldOrPropertyWithValue("errorCode", LspException.ErrorCode.DOWNLOAD_FAILED)
+ }
+
+ @Test
+ fun `tryDownloadLspArtifacts should not throw error on successful download`() {
+ val versions = listOf(ManifestManager.Version(serverVersion = "1.0.0"))
+ val target = ManifestManager.VersionTarget(contents = contents)
+ val spyArtifactHelper = spyk(artifactHelper)
+
+ every { spyArtifactHelper.downloadLspArtifacts(any(), any()) } returns true
+ mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt")
+ every { moveFilesFromSourceToDestination(any(), any()) } just Runs
+ every { extractZipFile(any(), any()) } just Runs
+
+ spyArtifactHelper.tryDownloadLspArtifacts(versions, target)
+ }
+
+ @Test
+ fun `validateFileHash should return false if expected hash is null`() {
+ assertThat(artifactHelper.validateFileHash(tempDir, null)).isFalse()
+ }
+
+ @Test
+ fun `validateFileHash should return false if hash did not match`() {
+ mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt")
+ every { generateSHA384Hash(any()) } returns "1234"
+ assertThat(artifactHelper.validateFileHash(tempDir, "1234")).isFalse()
+ }
+
+ @Test
+ fun `validateFileHash should return true if hash matched`() {
+ mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt")
+ every { generateSHA384Hash(any()) } returns "1234"
+ assertThat(artifactHelper.validateFileHash(tempDir, "sha384:1234")).isTrue()
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt
new file mode 100644
index 00000000000..756b8821b37
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt
@@ -0,0 +1,134 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
+
+import com.intellij.util.text.SemVer
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockkStatic
+import io.mockk.spyk
+import io.mockk.verify
+import org.assertj.core.api.Assertions.assertThatThrownBy
+import org.jetbrains.annotations.TestOnly
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager.SupportedManifestVersionRange
+import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
+import java.nio.file.Path
+
+@TestOnly
+class ArtifactManagerTest {
+
+ @TempDir
+ lateinit var tempDir: Path
+
+ private lateinit var artifactHelper: ArtifactHelper
+ private lateinit var artifactManager: ArtifactManager
+ private lateinit var manifestFetcher: ManifestFetcher
+ private lateinit var manifestVersionRanges: SupportedManifestVersionRange
+
+ @BeforeEach
+ fun setUp() {
+ artifactHelper = spyk(ArtifactHelper(tempDir, 3))
+ manifestFetcher = spyk(ManifestFetcher())
+ manifestVersionRanges = SupportedManifestVersionRange(
+ startVersion = SemVer("1.0.0", 1, 0, 0),
+ endVersion = SemVer("2.0.0", 2, 0, 0)
+ )
+ artifactManager = ArtifactManager(manifestFetcher, artifactHelper, manifestVersionRanges)
+ }
+
+ @Test
+ fun `fetch artifact fetcher throws exception if manifest is null`() {
+ every { manifestFetcher.fetch() }.returns(null)
+
+ assertThatThrownBy {
+ artifactManager.fetchArtifact()
+ }
+ .isInstanceOf(LspException::class.java)
+ .hasFieldOrPropertyWithValue("errorCode", LspException.ErrorCode.MANIFEST_FETCH_FAILED)
+ }
+
+ @Test
+ fun `fetch artifact does not have any valid lsp versions`() {
+ every { manifestFetcher.fetch() }.returns(ManifestManager.Manifest())
+ artifactManager = spyk(ArtifactManager(manifestFetcher, artifactHelper, manifestVersionRanges))
+
+ every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns(
+ ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = emptyList())
+ )
+
+ assertThatThrownBy {
+ artifactManager.fetchArtifact()
+ }
+ .isInstanceOf(LspException::class.java)
+ .hasFieldOrPropertyWithValue("errorCode", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION)
+ }
+
+ @Test
+ fun `fetch artifact if inRangeVersions are not available should fallback to local lsp`() {
+ val expectedResult = listOf(Pair(tempDir, SemVer("1.0.0", 1, 0, 0)))
+
+ every { manifestFetcher.fetch() }.returns(ManifestManager.Manifest())
+ every { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }.returns(expectedResult)
+
+ artifactManager.fetchArtifact()
+
+ verify(exactly = 1) { manifestFetcher.fetch() }
+ verify(exactly = 1) { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }
+ }
+
+ @Test
+ fun `fetch artifact have valid version in local system`() {
+ val target = ManifestManager.VersionTarget(platform = "temp", arch = "temp")
+ val versions = listOf(ManifestManager.Version("1.0.0", targets = listOf(target)))
+
+ artifactManager = spyk(ArtifactManager(manifestFetcher, artifactHelper, manifestVersionRanges))
+
+ every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns(
+ ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions)
+ )
+ every { manifestFetcher.fetch() }.returns(ManifestManager.Manifest())
+
+ mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt")
+ every { getCurrentOS() }.returns("temp")
+ every { getCurrentArchitecture() }.returns("temp")
+
+ every { artifactHelper.getExistingLspArtifacts(any(), any()) }.returns(false)
+ every { artifactHelper.tryDownloadLspArtifacts(any(), any()) } just Runs
+ every { artifactHelper.deleteOlderLspArtifacts(any()) } just Runs
+
+ artifactManager.fetchArtifact()
+
+ verify(exactly = 1) { artifactHelper.tryDownloadLspArtifacts(any(), any()) }
+ verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) }
+ }
+
+ @Test
+ fun `fetch artifact does not have valid version in local system`() {
+ val target = ManifestManager.VersionTarget(platform = "temp", arch = "temp")
+ val versions = listOf(ManifestManager.Version("1.0.0", targets = listOf(target)))
+
+ artifactManager = spyk(ArtifactManager(manifestFetcher, artifactHelper, manifestVersionRanges))
+
+ every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns(
+ ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions)
+ )
+ every { manifestFetcher.fetch() }.returns(ManifestManager.Manifest())
+
+ mockkStatic("software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.LspUtilsKt")
+ every { getCurrentOS() }.returns("temp")
+ every { getCurrentArchitecture() }.returns("temp")
+
+ every { artifactHelper.getExistingLspArtifacts(any(), any()) }.returns(true)
+ every { artifactHelper.deleteOlderLspArtifacts(any()) } just Runs
+
+ artifactManager.fetchArtifact()
+
+ verify(exactly = 0) { artifactHelper.tryDownloadLspArtifacts(any(), any()) }
+ verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) }
+ }
+}
From a74f884991aaadb6a494655fd28863bfd34313a3 Mon Sep 17 00:00:00 2001
From: Sam Stewart
Date: Fri, 28 Feb 2025 11:04:56 -0800
Subject: [PATCH 013/117] implement token change listener (#5423)
---
.../services/amazonq/lsp/AmazonQLspService.kt | 3 ++
.../lsp/auth/DefaultAuthCredentialsService.kt | 32 ++++++++++++++++++-
.../auth/DefaultAuthCredentialsServiceTest.kt | 14 +++++++-
3 files changed, 47 insertions(+), 2 deletions(-)
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
index 49141741de0..db49cb5a56e 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
@@ -45,6 +45,7 @@ import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.info
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.isDeveloperMode
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService
import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.createExtendedClientMetadata
import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata
@@ -303,6 +304,8 @@ private class AmazonQServerInstance(private val project: Project, private val cs
}
languageServer.initialized(InitializedParams())
}
+
+ DefaultAuthCredentialsService(project, encryptionManager, this)
}
override fun dispose() {
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt
index 6fdc3bf1ef8..5509e6cb688 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsService.kt
@@ -3,8 +3,14 @@
package software.aws.toolkits.jetbrains.services.amazonq.lsp.auth
+import com.intellij.openapi.Disposable
import com.intellij.openapi.project.Project
import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage
+import software.aws.toolkits.core.TokenConnectionSettings
+import software.aws.toolkits.jetbrains.core.credentials.ToolkitConnectionManager
+import software.aws.toolkits.jetbrains.core.credentials.pinning.QConnection
+import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProvider
+import software.aws.toolkits.jetbrains.core.credentials.sso.bearer.BearerTokenProviderListener
import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService
import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.BearerCredentials
@@ -15,7 +21,31 @@ import java.util.concurrent.CompletableFuture
class DefaultAuthCredentialsService(
private val project: Project,
private val encryptionManager: JwtEncryptionManager,
-) : AuthCredentialsService {
+ serverInstance: Disposable,
+) : AuthCredentialsService,
+ BearerTokenProviderListener {
+ init {
+ project.messageBus.connect(serverInstance).subscribe(BearerTokenProviderListener.TOPIC, this)
+ }
+
+ override fun onChange(providerId: String, newScopes: List?) {
+ val connection = ToolkitConnectionManager.getInstance(project)
+ .activeConnectionForFeature(QConnection.getInstance())
+ ?: return
+
+ val provider = (connection.getConnectionSettings() as? TokenConnectionSettings)
+ ?.tokenProvider
+ ?.delegate as? BearerTokenProvider
+ ?: return
+
+ provider.currentToken()?.accessToken?.let { token ->
+ updateTokenCredentials(token, false)
+ }
+ }
+
+ override fun invalidate(providerId: String) {
+ deleteTokenCredentials()
+ }
override fun updateTokenCredentials(accessToken: String, encrypted: Boolean): CompletableFuture {
val token = if (encrypted) {
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt
index 2c4c003ba8c..95d5f6e38d8 100644
--- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt
@@ -3,10 +3,15 @@
package software.aws.toolkits.jetbrains.services.amazonq.lsp.auth
+import com.intellij.openapi.Disposable
import com.intellij.openapi.components.serviceIfCreated
import com.intellij.openapi.project.Project
+import com.intellij.util.messages.MessageBus
+import com.intellij.util.messages.MessageBusConnection
import io.mockk.every
+import io.mockk.just
import io.mockk.mockk
+import io.mockk.runs
import io.mockk.verify
import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage
import org.junit.Before
@@ -42,7 +47,14 @@ class DefaultAuthCredentialsServiceTest {
func.invoke(mockLanguageServer)
}
- sut = DefaultAuthCredentialsService(project, this.mockEncryptionManager)
+ // Mock message bus
+ val messageBus = mockk()
+ every { project.messageBus } returns messageBus
+ val mockConnection = mockk()
+ every { messageBus.connect(any()) } returns mockConnection
+ every { mockConnection.subscribe(any(), any()) } just runs
+
+ sut = DefaultAuthCredentialsService(project, this.mockEncryptionManager, mockk())
}
@Test
From 0af95e3af7452b55365d0a678294ebe8ddacf295 Mon Sep 17 00:00:00 2001
From: Lokesh
Date: Fri, 28 Feb 2025 15:26:34 -0800
Subject: [PATCH 014/117] feat(amazonq): Added progress indicator for lsp
artifact download (#5426)
* Added download progress indicator
* FetchArtifacts will return path now
---
.../amazonq/lsp/artifacts/ArtifactHelper.kt | 39 +++++++++++++------
.../amazonq/lsp/artifacts/ArtifactManager.kt | 20 +++++-----
.../lsp/artifacts/ArtifactHelperTest.kt | 25 ++++++------
.../lsp/artifacts/ArtifactManagerTest.kt | 35 +++++++++++------
.../resources/MessagesBundle.properties | 1 +
5 files changed, 74 insertions(+), 46 deletions(-)
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt
index e22bea572ad..e0fe212b9d4 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt
@@ -3,8 +3,11 @@
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
+import com.intellij.openapi.project.Project
+import com.intellij.platform.ide.progress.withBackgroundProgress
import com.intellij.util.io.createDirectories
import com.intellij.util.text.SemVer
+import kotlinx.coroutines.CancellationException
import org.jetbrains.annotations.VisibleForTesting
import software.aws.toolkits.core.utils.deleteIfExists
import software.aws.toolkits.core.utils.error
@@ -14,6 +17,7 @@ import software.aws.toolkits.core.utils.info
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.core.saveFileFromUrl
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
+import software.aws.toolkits.resources.AwsCoreBundle
import java.nio.file.Path
import java.util.concurrent.atomic.AtomicInteger
@@ -99,7 +103,7 @@ class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH,
return !hasInvalidFiles
}
- fun tryDownloadLspArtifacts(versions: List, target: ManifestManager.VersionTarget?) {
+ suspend fun tryDownloadLspArtifacts(project: Project, versions: List, target: ManifestManager.VersionTarget?): Path? {
val temporaryDownloadPath = lspArtifactsPath.resolve("temp")
val downloadPath = lspArtifactsPath.resolve(versions.first().serverVersion.toString())
@@ -108,23 +112,34 @@ class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH,
logger.info { "Attempt ${currentAttempt.get()} of $maxDownloadAttempts to download LSP artifacts" }
try {
- if (downloadLspArtifacts(temporaryDownloadPath, target) && target != null && !target.contents.isNullOrEmpty()) {
- moveFilesFromSourceToDestination(temporaryDownloadPath, downloadPath)
- target.contents
- .mapNotNull { it.filename }
- .forEach { filename -> extractZipFile(downloadPath.resolve(filename), downloadPath) }
- logger.info { "Successfully downloaded and moved LSP artifacts to $downloadPath" }
- return
+ withBackgroundProgress(
+ project,
+ AwsCoreBundle.message("amazonqFeatureDev.placeholder.downloading_and_extracting_lsp_artifacts"),
+ cancellable = true
+ ) {
+ if (downloadLspArtifacts(temporaryDownloadPath, target) && target != null && !target.contents.isNullOrEmpty()) {
+ moveFilesFromSourceToDestination(temporaryDownloadPath, downloadPath)
+ target.contents
+ .mapNotNull { it.filename }
+ .forEach { filename -> extractZipFile(downloadPath.resolve(filename), downloadPath) }
+ logger.info { "Successfully downloaded and moved LSP artifacts to $downloadPath" }
+ }
}
+ return downloadPath
} catch (e: Exception) {
- logger.error(e) { "Failed to download/move LSP artifacts on attempt ${currentAttempt.get()}" }
+ when (e) {
+ is CancellationException -> {
+ logger.error(e) { "User cancelled download and extracting of LSP artifacts.." }
+ currentAttempt.set(maxDownloadAttempts) // To exit the while loop.
+ }
+ else -> { logger.error(e) { "Failed to download/move LSP artifacts on attempt ${currentAttempt.get()}" } }
+ }
temporaryDownloadPath.toFile().deleteRecursively()
downloadPath.toFile().deleteRecursively()
}
}
- if (currentAttempt.get() >= maxDownloadAttempts) {
- throw LspException("Failed to download LSP artifacts after $maxDownloadAttempts attempts", LspException.ErrorCode.DOWNLOAD_FAILED)
- }
+ logger.error { "Failed to download LSP artifacts after $maxDownloadAttempts attempts" }
+ return null
}
@VisibleForTesting
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt
index 0286bd89939..7d41bfaddb5 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManager.kt
@@ -3,14 +3,17 @@
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
+import com.intellij.openapi.project.Project
import com.intellij.util.text.SemVer
import org.jetbrains.annotations.VisibleForTesting
import software.aws.toolkits.core.utils.error
import software.aws.toolkits.core.utils.getLogger
import software.aws.toolkits.core.utils.info
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
+import java.nio.file.Path
class ArtifactManager(
+ private val project: Project,
private val manifestFetcher: ManifestFetcher = ManifestFetcher(),
private val artifactHelper: ArtifactHelper = ArtifactHelper(),
manifestRange: SupportedManifestVersionRange?,
@@ -27,9 +30,6 @@ class ArtifactManager(
private val manifestVersionRanges: SupportedManifestVersionRange = manifestRange ?: DEFAULT_VERSION_RANGE
- // Secondary constructor with no parameters
- constructor() : this(ManifestFetcher(), ArtifactHelper(), null)
-
companion object {
private val DEFAULT_VERSION_RANGE = SupportedManifestVersionRange(
startVersion = SemVer("3.0.0", 3, 0, 0),
@@ -38,7 +38,7 @@ class ArtifactManager(
private val logger = getLogger()
}
- fun fetchArtifact() {
+ suspend fun fetchArtifact(): Path {
val manifest = manifestFetcher.fetch() ?: throw LspException(
"Language Support is not available, as manifest is missing.",
LspException.ErrorCode.MANIFEST_FETCH_FAILED
@@ -51,20 +51,22 @@ class ArtifactManager(
// No versions are found which are in the given range. Fallback to local lsp artifacts.
val localLspArtifacts = this.artifactHelper.getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges)
if (localLspArtifacts.isNotEmpty()) {
- return
+ return localLspArtifacts.first().first
}
throw LspException("Language server versions not found in manifest.", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION)
}
// If there is an LSP Manifest with the same version
val target = getTargetFromLspManifest(lspVersions.inRangeVersions)
-
// Get Local LSP files and check if we can re-use existing LSP Artifacts
- if (!this.artifactHelper.getExistingLspArtifacts(lspVersions.inRangeVersions, target)) {
- this.artifactHelper.tryDownloadLspArtifacts(lspVersions.inRangeVersions, target)
+ val artifactPath: Path = if (this.artifactHelper.getExistingLspArtifacts(lspVersions.inRangeVersions, target)) {
+ this.artifactHelper.getAllLocalLspArtifactsWithinManifestRange(manifestVersionRanges).first().first
+ } else {
+ this.artifactHelper.tryDownloadLspArtifacts(project, lspVersions.inRangeVersions, target)
+ ?: throw LspException("Failed to download LSP artifacts", LspException.ErrorCode.DOWNLOAD_FAILED)
}
-
this.artifactHelper.deleteOlderLspArtifacts(manifestVersionRanges)
+ return artifactPath
}
@VisibleForTesting
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt
index 8357fb78b82..cf0dc137901 100644
--- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt
@@ -3,15 +3,17 @@
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
+import com.intellij.openapi.project.Project
import com.intellij.util.io.createDirectories
import com.intellij.util.text.SemVer
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
+import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.spyk
+import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
-import org.assertj.core.api.Assertions.assertThatThrownBy
import org.jetbrains.annotations.TestOnly
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
@@ -30,6 +32,7 @@ class ArtifactHelperTest {
private lateinit var manifestVersionRanges: SupportedManifestVersionRange
private lateinit var mockManifestManager: ManifestManager
private lateinit var contents: List
+ private lateinit var mockProject: Project
@BeforeEach
fun setUp() {
@@ -41,6 +44,10 @@ class ArtifactHelperTest {
hashes = listOf("sha384:1234")
)
)
+ mockProject = mockk(relaxed = true) {
+ every { basePath } returns tempDir.toString()
+ every { name } returns "TestProject"
+ }
}
@Test
@@ -180,11 +187,7 @@ class ArtifactHelperTest {
@Test
fun `tryDownloadLspArtifacts should not download artifacts if target does not have contents`() {
val versions = listOf(ManifestManager.Version(serverVersion = "2.0.0"))
- assertThatThrownBy {
- artifactHelper.tryDownloadLspArtifacts(versions, null)
- }
- .isInstanceOf(LspException::class.java)
- .hasFieldOrPropertyWithValue("errorCode", LspException.ErrorCode.DOWNLOAD_FAILED)
+ assertThat(runBlocking { artifactHelper.tryDownloadLspArtifacts(mockProject, versions, null) }).isEqualTo(null)
assertThat(tempDir.resolve("2.0.0").toFile().exists()).isFalse()
}
@@ -195,15 +198,11 @@ class ArtifactHelperTest {
val spyArtifactHelper = spyk(artifactHelper)
every { spyArtifactHelper.downloadLspArtifacts(any(), any()) } returns false
- assertThatThrownBy {
- spyArtifactHelper.tryDownloadLspArtifacts(versions, null)
- }
- .isInstanceOf(LspException::class.java)
- .hasFieldOrPropertyWithValue("errorCode", LspException.ErrorCode.DOWNLOAD_FAILED)
+ assertThat(runBlocking { artifactHelper.tryDownloadLspArtifacts(mockProject, versions, null) }).isEqualTo(null)
}
@Test
- fun `tryDownloadLspArtifacts should not throw error on successful download`() {
+ fun `tryDownloadLspArtifacts should throw error after attempts are exhausted`() {
val versions = listOf(ManifestManager.Version(serverVersion = "1.0.0"))
val target = ManifestManager.VersionTarget(contents = contents)
val spyArtifactHelper = spyk(artifactHelper)
@@ -213,7 +212,7 @@ class ArtifactHelperTest {
every { moveFilesFromSourceToDestination(any(), any()) } just Runs
every { extractZipFile(any(), any()) } just Runs
- spyArtifactHelper.tryDownloadLspArtifacts(versions, target)
+ assertThat(runBlocking { artifactHelper.tryDownloadLspArtifacts(mockProject, versions, target) }).isEqualTo(null)
}
@Test
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt
index 756b8821b37..4e163900bff 100644
--- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactManagerTest.kt
@@ -3,13 +3,17 @@
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
+import com.intellij.openapi.project.Project
import com.intellij.util.text.SemVer
import io.mockk.Runs
+import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
+import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.spyk
import io.mockk.verify
+import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.jetbrains.annotations.TestOnly
import org.junit.jupiter.api.BeforeEach
@@ -29,6 +33,7 @@ class ArtifactManagerTest {
private lateinit var artifactManager: ArtifactManager
private lateinit var manifestFetcher: ManifestFetcher
private lateinit var manifestVersionRanges: SupportedManifestVersionRange
+ private lateinit var mockProject: Project
@BeforeEach
fun setUp() {
@@ -38,7 +43,11 @@ class ArtifactManagerTest {
startVersion = SemVer("1.0.0", 1, 0, 0),
endVersion = SemVer("2.0.0", 2, 0, 0)
)
- artifactManager = ArtifactManager(manifestFetcher, artifactHelper, manifestVersionRanges)
+ mockProject = mockk(relaxed = true) {
+ every { basePath } returns tempDir.toString()
+ every { name } returns "TestProject"
+ }
+ artifactManager = ArtifactManager(mockProject, manifestFetcher, artifactHelper, manifestVersionRanges)
}
@Test
@@ -46,7 +55,7 @@ class ArtifactManagerTest {
every { manifestFetcher.fetch() }.returns(null)
assertThatThrownBy {
- artifactManager.fetchArtifact()
+ runBlocking { artifactManager.fetchArtifact() }
}
.isInstanceOf(LspException::class.java)
.hasFieldOrPropertyWithValue("errorCode", LspException.ErrorCode.MANIFEST_FETCH_FAILED)
@@ -55,14 +64,14 @@ class ArtifactManagerTest {
@Test
fun `fetch artifact does not have any valid lsp versions`() {
every { manifestFetcher.fetch() }.returns(ManifestManager.Manifest())
- artifactManager = spyk(ArtifactManager(manifestFetcher, artifactHelper, manifestVersionRanges))
+ artifactManager = spyk(ArtifactManager(mockProject, manifestFetcher, artifactHelper, manifestVersionRanges))
every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns(
ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = emptyList())
)
assertThatThrownBy {
- artifactManager.fetchArtifact()
+ runBlocking { artifactManager.fetchArtifact() }
}
.isInstanceOf(LspException::class.java)
.hasFieldOrPropertyWithValue("errorCode", LspException.ErrorCode.NO_COMPATIBLE_LSP_VERSION)
@@ -75,7 +84,7 @@ class ArtifactManagerTest {
every { manifestFetcher.fetch() }.returns(ManifestManager.Manifest())
every { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }.returns(expectedResult)
- artifactManager.fetchArtifact()
+ runBlocking { artifactManager.fetchArtifact() }
verify(exactly = 1) { manifestFetcher.fetch() }
verify(exactly = 1) { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }
@@ -86,7 +95,7 @@ class ArtifactManagerTest {
val target = ManifestManager.VersionTarget(platform = "temp", arch = "temp")
val versions = listOf(ManifestManager.Version("1.0.0", targets = listOf(target)))
- artifactManager = spyk(ArtifactManager(manifestFetcher, artifactHelper, manifestVersionRanges))
+ artifactManager = spyk(ArtifactManager(mockProject, manifestFetcher, artifactHelper, manifestVersionRanges))
every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns(
ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions)
@@ -98,12 +107,12 @@ class ArtifactManagerTest {
every { getCurrentArchitecture() }.returns("temp")
every { artifactHelper.getExistingLspArtifacts(any(), any()) }.returns(false)
- every { artifactHelper.tryDownloadLspArtifacts(any(), any()) } just Runs
+ coEvery { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } returns tempDir
every { artifactHelper.deleteOlderLspArtifacts(any()) } just Runs
- artifactManager.fetchArtifact()
+ runBlocking { artifactManager.fetchArtifact() }
- verify(exactly = 1) { artifactHelper.tryDownloadLspArtifacts(any(), any()) }
+ verify(exactly = 1) { runBlocking { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } }
verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) }
}
@@ -111,8 +120,9 @@ class ArtifactManagerTest {
fun `fetch artifact does not have valid version in local system`() {
val target = ManifestManager.VersionTarget(platform = "temp", arch = "temp")
val versions = listOf(ManifestManager.Version("1.0.0", targets = listOf(target)))
+ val expectedResult = listOf(Pair(tempDir, SemVer("1.0.0", 1, 0, 0)))
- artifactManager = spyk(ArtifactManager(manifestFetcher, artifactHelper, manifestVersionRanges))
+ artifactManager = spyk(ArtifactManager(mockProject, manifestFetcher, artifactHelper, manifestVersionRanges))
every { artifactManager.getLSPVersionsFromManifestWithSpecifiedRange(any()) }.returns(
ArtifactManager.LSPVersions(deListedVersions = emptyList(), inRangeVersions = versions)
@@ -125,10 +135,11 @@ class ArtifactManagerTest {
every { artifactHelper.getExistingLspArtifacts(any(), any()) }.returns(true)
every { artifactHelper.deleteOlderLspArtifacts(any()) } just Runs
+ every { artifactHelper.getAllLocalLspArtifactsWithinManifestRange(any()) }.returns(expectedResult)
- artifactManager.fetchArtifact()
+ runBlocking { artifactManager.fetchArtifact() }
- verify(exactly = 0) { artifactHelper.tryDownloadLspArtifacts(any(), any()) }
+ verify(exactly = 0) { runBlocking { artifactHelper.tryDownloadLspArtifacts(any(), any(), any()) } }
verify(exactly = 1) { artifactHelper.deleteOlderLspArtifacts(any()) }
}
}
diff --git a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties
index 87c11fd21cb..2d756cb51b5 100644
--- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties
+++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties
@@ -141,6 +141,7 @@ amazonqFeatureDev.placeholder.after_code_generation=Choose an option to proceed
amazonqFeatureDev.placeholder.after_monthly_limit=Chat input is disabled
amazonqFeatureDev.placeholder.closed_session=Open a new chat tab to continue
amazonqFeatureDev.placeholder.context_gathering_complete=Gathering context...
+amazonqFeatureDev.placeholder.downloading_and_extracting_lsp_artifacts=Downloading and Extracting Lsp Artifacts...
amazonqFeatureDev.placeholder.generating_code=Generating code...
amazonqFeatureDev.placeholder.new_plan=Describe your task or issue in as much detail as possible
amazonqFeatureDev.placeholder.provide_code_feedback=Provide feedback or comments
From 1fd1e73730f36cdc86250e0e6398593a1b5dd92d Mon Sep 17 00:00:00 2001
From: Sam Stewart
Date: Mon, 3 Mar 2025 11:13:50 -0800
Subject: [PATCH 015/117] feat(amazonq): add virtualFile -> URI util (#5381)
* add virtualFile -> URI util
* add string function
* utility object instead of class
* tests
* detekt
* detekt
* redhat impl
* deteckt
* missing license
* fix tests
* detekt
* OS handling for tests
* detekt
---
.../services/amazonq/lsp/util/FileUriUtil.kt | 42 +++++
.../amazonq/lsp/util/FileUriUtilTest.kt | 150 ++++++++++++++++++
2 files changed, 192 insertions(+)
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtil.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtil.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtil.kt
new file mode 100644
index 00000000000..b2821257a49
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtil.kt
@@ -0,0 +1,42 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.util
+
+import com.intellij.openapi.vfs.VfsUtilCore
+import com.intellij.openapi.vfs.VirtualFile
+import software.aws.toolkits.core.utils.getLogger
+import software.aws.toolkits.core.utils.warn
+import java.io.File
+import java.net.URI
+import java.net.URISyntaxException
+
+object FileUriUtil {
+
+ fun toUriString(virtualFile: VirtualFile): String? {
+ val protocol = virtualFile.fileSystem.protocol
+ val uri = when (protocol) {
+ "jar" -> VfsUtilCore.convertToURL(virtualFile.url)?.toExternalForm()
+ "jrt" -> virtualFile.url
+ else -> toUri(VfsUtilCore.virtualToIoFile(virtualFile)).toASCIIString()
+ } ?: return null
+
+ return if (virtualFile.isDirectory) {
+ uri.trimEnd('/', '\\')
+ } else {
+ uri
+ }
+ }
+
+ private fun toUri(file: File): URI {
+ try {
+ // URI scheme specified by language server protocol
+ return URI("file", "", file.absoluteFile.toURI().path, null)
+ } catch (e: URISyntaxException) {
+ LOG.warn { "${e.localizedMessage}: $e" }
+ return file.absoluteFile.toURI()
+ }
+ }
+
+ private val LOG = getLogger()
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt
new file mode 100644
index 00000000000..26c420779d0
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt
@@ -0,0 +1,150 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.util
+
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.testFramework.fixtures.BasePlatformTestCase
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Test
+
+class FileUriUtilTest : BasePlatformTestCase() {
+
+ private fun createMockVirtualFile(path: String, protocol: String = "file", isDirectory: Boolean = false): VirtualFile =
+ mockk {
+ every { fileSystem } returns mockk {
+ every { this@mockk.protocol } returns protocol
+ }
+ every { url } returns path
+ every { this@mockk.isDirectory } returns isDirectory
+ }
+
+ private fun normalizeFileUri(uri: String): String {
+ if (!System.getProperty("os.name").lowercase().contains("windows")) {
+ return uri
+ }
+
+ if (!uri.startsWith("file:///")) {
+ return uri
+ }
+
+ val path = uri.substringAfter("file:///")
+ return "file:///C:/$path"
+ }
+
+ @Test
+ fun `test basic unix path`() {
+ val virtualFile = createMockVirtualFile("/path/to/file.txt")
+ val uri = FileUriUtil.toUriString(virtualFile)
+ val expected = normalizeFileUri("file:///path/to/file.txt")
+ assertEquals(expected, uri)
+ }
+
+ @Test
+ fun `test unix directory path`() {
+ val virtualFile = createMockVirtualFile("/path/to/directory/", isDirectory = true)
+ val uri = FileUriUtil.toUriString(virtualFile)
+ val expected = normalizeFileUri("file:///path/to/directory")
+ assertEquals(expected, uri)
+ }
+
+ @Test
+ fun `test path with spaces`() {
+ val virtualFile = createMockVirtualFile("/path/with spaces/file.txt")
+ val uri = FileUriUtil.toUriString(virtualFile)
+ val expected = normalizeFileUri("file:///path/with%20spaces/file.txt")
+ assertEquals(expected, uri)
+ }
+
+ @Test
+ fun `test root path`() {
+ val virtualFile = createMockVirtualFile("/")
+ val uri = FileUriUtil.toUriString(virtualFile)
+ val expected = normalizeFileUri("file:///")
+ assertEquals(expected, uri)
+ }
+
+ @Test
+ fun `test path with multiple separators`() {
+ val virtualFile = createMockVirtualFile("/path//to///file.txt")
+ val uri = FileUriUtil.toUriString(virtualFile)
+ val expected = normalizeFileUri("file:///path/to/file.txt")
+ assertEquals(expected, uri)
+ }
+
+ @Test
+ fun `test very long path`() {
+ val longPath = "/a".repeat(256) + "/file.txt"
+ val virtualFile = createMockVirtualFile(longPath)
+ val uri = FileUriUtil.toUriString(virtualFile)
+ if (uri != null) {
+ assertTrue(uri.startsWith("file:///"))
+ assertTrue(uri.endsWith("/file.txt"))
+ }
+ }
+
+ @Test
+ fun `test relative path`() {
+ val virtualFile = createMockVirtualFile("./relative/path/file.txt")
+ val uri = FileUriUtil.toUriString(virtualFile)
+ if (uri != null) {
+ assertTrue(uri.contains("file.txt"))
+ assertTrue(uri.startsWith("file:///"))
+ }
+ }
+
+ @Test
+ fun `test jar protocol conversion`() {
+ val virtualFile = createMockVirtualFile(
+ "jar:file:///path/to/archive.jar!/com/example/Test.class",
+ "jar"
+ )
+ val result = FileUriUtil.toUriString(virtualFile)
+ val expected = normalizeFileUri("jar:file:///path/to/archive.jar!/com/example/Test.class")
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `test jrt protocol conversion`() {
+ val virtualFile = createMockVirtualFile(
+ "jrt://java.base/java/lang/String.class",
+ "jrt"
+ )
+ val result = FileUriUtil.toUriString(virtualFile)
+ val expected = normalizeFileUri("jrt://java.base/java/lang/String.class")
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `test invalid jar url returns null`() {
+ val virtualFile = createMockVirtualFile(
+ "invalid:url:format",
+ "jar"
+ )
+ val result = FileUriUtil.toUriString(virtualFile)
+ assertNull(result)
+ }
+
+ @Test
+ fun `test jar protocol with directory`() {
+ val virtualFile = createMockVirtualFile(
+ "jar:file:///path/to/archive.jar!/com/example/",
+ "jar",
+ true
+ )
+ val result = FileUriUtil.toUriString(virtualFile)
+ val expected = normalizeFileUri("jar:file:///path/to/archive.jar!/com/example")
+ assertEquals(expected, result)
+ }
+
+ @Test
+ fun `test empty url in jar protocol`() {
+ val virtualFile = createMockVirtualFile(
+ "",
+ "jar",
+ true
+ )
+ val result = FileUriUtil.toUriString(virtualFile)
+ assertNull(result)
+ }
+}
From cb97ce75f96ca3feac11e41870d67cb22e0413e3 Mon Sep 17 00:00:00 2001
From: Sam Stewart
Date: Mon, 3 Mar 2025 13:58:50 -0800
Subject: [PATCH 016/117] feat(amazonq): implement workspace file messages
(#5377)
Implement both
workspace/didCreateFiles
workspace/didDeleteFiles
---
.../services/amazonq/lsp/AmazonQLspService.kt | 19 +-
.../amazonq/lsp/util/WorkspaceFolderUtil.kt | 22 +
.../lsp/workspace/WorkspaceServiceHandler.kt | 198 +++++++
.../lsp/util/WorkspaceFolderUtilTest.kt | 64 ++
.../workspace/WorkspaceServiceHandlerTest.kt | 560 ++++++++++++++++++
5 files changed, 849 insertions(+), 14 deletions(-)
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtil.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtilTest.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
index db49cb5a56e..0d774e5069a 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
@@ -37,7 +37,6 @@ import org.eclipse.lsp4j.InitializedParams
import org.eclipse.lsp4j.SynchronizationCapabilities
import org.eclipse.lsp4j.TextDocumentClientCapabilities
import org.eclipse.lsp4j.WorkspaceClientCapabilities
-import org.eclipse.lsp4j.WorkspaceFolder
import org.eclipse.lsp4j.jsonrpc.Launcher
import org.eclipse.lsp4j.launch.LSPLauncher
import org.slf4j.event.Level
@@ -48,6 +47,8 @@ import software.aws.toolkits.jetbrains.isDeveloperMode
import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService
import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.createExtendedClientMetadata
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace.WorkspaceServiceHandler
import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata
import java.io.IOException
import java.io.OutputStreamWriter
@@ -55,7 +56,6 @@ import java.io.PipedInputStream
import java.io.PipedOutputStream
import java.io.PrintWriter
import java.io.StringWriter
-import java.net.URI
import java.nio.charset.StandardCharsets
import java.util.concurrent.Future
import kotlin.time.Duration.Companion.seconds
@@ -211,21 +211,11 @@ private class AmazonQServerInstance(private val project: Project, private val cs
fileOperations = FileOperationsWorkspaceCapabilities().apply {
didCreate = true
didDelete = true
+ didRename = true
}
}
}
- // needs case handling when project's base path is null: default projects/unit tests
- private fun createWorkspaceFolders(): List =
- project.basePath?.let { basePath ->
- listOf(
- WorkspaceFolder(
- URI("file://$basePath").toString(),
- project.name
- )
- )
- }.orEmpty() // no folders to report or workspace not folder based
-
private fun createClientInfo(): ClientInfo {
val metadata = ClientMetadata.getDefault()
return ClientInfo().apply {
@@ -239,7 +229,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs
processId = ProcessHandle.current().pid().toInt()
capabilities = createClientCapabilities()
clientInfo = createClientInfo()
- workspaceFolders = createWorkspaceFolders()
+ workspaceFolders = createWorkspaceFolders(project)
initializationOptions = createExtendedClientMetadata()
}
@@ -306,6 +296,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs
}
DefaultAuthCredentialsService(project, encryptionManager, this)
+ WorkspaceServiceHandler(project, this)
}
override fun dispose() {
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtil.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtil.kt
new file mode 100644
index 00000000000..9722ab8c85e
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtil.kt
@@ -0,0 +1,22 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.util
+
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.roots.ProjectRootManager
+import org.eclipse.lsp4j.WorkspaceFolder
+
+object WorkspaceFolderUtil {
+ fun createWorkspaceFolders(project: Project): List =
+ if (project.isDefault) {
+ emptyList()
+ } else {
+ ProjectRootManager.getInstance(project).contentRoots.map { contentRoot ->
+ WorkspaceFolder().apply {
+ name = contentRoot.name
+ this.uri = contentRoot.url
+ }
+ }
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt
new file mode 100644
index 00000000000..30e1e08713c
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandler.kt
@@ -0,0 +1,198 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace
+
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.roots.ModuleRootEvent
+import com.intellij.openapi.roots.ModuleRootListener
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.openapi.vfs.VirtualFileManager
+import com.intellij.openapi.vfs.newvfs.BulkFileListener
+import com.intellij.openapi.vfs.newvfs.events.VFileCreateEvent
+import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent
+import com.intellij.openapi.vfs.newvfs.events.VFileEvent
+import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent
+import org.eclipse.lsp4j.CreateFilesParams
+import org.eclipse.lsp4j.DeleteFilesParams
+import org.eclipse.lsp4j.DidChangeWatchedFilesParams
+import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams
+import org.eclipse.lsp4j.FileChangeType
+import org.eclipse.lsp4j.FileCreate
+import org.eclipse.lsp4j.FileDelete
+import org.eclipse.lsp4j.FileEvent
+import org.eclipse.lsp4j.FileRename
+import org.eclipse.lsp4j.RenameFilesParams
+import org.eclipse.lsp4j.WorkspaceFolder
+import org.eclipse.lsp4j.WorkspaceFoldersChangeEvent
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders
+import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread
+import java.nio.file.FileSystems
+import java.nio.file.Paths
+
+class WorkspaceServiceHandler(
+ private val project: Project,
+ serverInstance: Disposable,
+) : BulkFileListener,
+ ModuleRootListener {
+
+ private var lastSnapshot: List = emptyList()
+ private val supportedFilePatterns = FileSystems.getDefault().getPathMatcher(
+ "glob:**/*.{ts,js,py,java}"
+ )
+
+ init {
+ project.messageBus.connect(serverInstance).subscribe(
+ VirtualFileManager.VFS_CHANGES,
+ this
+ )
+
+ project.messageBus.connect(serverInstance).subscribe(
+ ModuleRootListener.TOPIC,
+ this
+ )
+ }
+
+ private fun didCreateFiles(events: List) {
+ AmazonQLspService.executeIfRunning(project) { languageServer ->
+ val validFiles = events.mapNotNull { event ->
+ val file = event.file?.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null
+ file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.let { uri ->
+ FileCreate().apply {
+ this.uri = uri
+ }
+ }
+ }
+
+ if (validFiles.isNotEmpty()) {
+ languageServer.workspaceService.didCreateFiles(
+ CreateFilesParams().apply {
+ files = validFiles
+ }
+ )
+ }
+ }
+ }
+
+ private fun didDeleteFiles(events: List) {
+ AmazonQLspService.executeIfRunning(project) { languageServer ->
+ val validFiles = events.mapNotNull { event ->
+ val file = event.file?.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null
+ file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.let { uri ->
+ FileDelete().apply {
+ this.uri = uri
+ }
+ }
+ }
+
+ if (validFiles.isNotEmpty()) {
+ languageServer.workspaceService.didDeleteFiles(
+ DeleteFilesParams().apply {
+ files = validFiles
+ }
+ )
+ }
+ }
+ }
+
+ private fun didRenameFiles(events: List) {
+ AmazonQLspService.executeIfRunning(project) { languageServer ->
+ val validRenames = events
+ .filter { it.propertyName == VirtualFile.PROP_NAME }
+ .mapNotNull { event ->
+ val file = event.file.takeIf { shouldHandleFile(it) } ?: return@mapNotNull null
+ val oldName = event.oldValue as? String ?: return@mapNotNull null
+ if (event.newValue !is String) return@mapNotNull null
+
+ // Construct old and new URIs
+ val parentPath = file.parent?.toNioPath() ?: return@mapNotNull null
+ val oldUri = parentPath.resolve(oldName).toUri().toString()
+ val newUri = file.toNioPath().toUri().toString()
+
+ FileRename().apply {
+ this.oldUri = oldUri
+ this.newUri = newUri
+ }
+ }
+
+ if (validRenames.isNotEmpty()) {
+ languageServer.workspaceService.didRenameFiles(
+ RenameFilesParams().apply {
+ files = validRenames
+ }
+ )
+ }
+ }
+ }
+
+ private fun didChangeWatchedFiles(events: List) {
+ AmazonQLspService.executeIfRunning(project) { languageServer ->
+ val validChanges = events.mapNotNull { event ->
+ event.file?.toNioPath()?.toUri()?.toString()?.takeIf { it.isNotEmpty() }?.let { uri ->
+ FileEvent().apply {
+ this.uri = uri
+ type = when (event) {
+ is VFileCreateEvent -> FileChangeType.Created
+ is VFileDeleteEvent -> FileChangeType.Deleted
+ else -> FileChangeType.Changed
+ }
+ }
+ }
+ }
+
+ if (validChanges.isNotEmpty()) {
+ languageServer.workspaceService.didChangeWatchedFiles(
+ DidChangeWatchedFilesParams().apply {
+ changes = validChanges
+ }
+ )
+ }
+ }
+ }
+
+ override fun after(events: List) {
+ // since we are using synchronous FileListener
+ pluginAwareExecuteOnPooledThread {
+ didCreateFiles(events.filterIsInstance())
+ didDeleteFiles(events.filterIsInstance())
+ didRenameFiles(events.filterIsInstance())
+ didChangeWatchedFiles(events)
+ }
+ }
+
+ override fun beforeRootsChange(event: ModuleRootEvent) {
+ lastSnapshot = createWorkspaceFolders(project)
+ }
+
+ override fun rootsChanged(event: ModuleRootEvent) {
+ AmazonQLspService.executeIfRunning(project) { languageServer ->
+ val currentSnapshot = createWorkspaceFolders(project)
+ val addedFolders = currentSnapshot.filter { folder -> lastSnapshot.none { it.uri == folder.uri } }
+ val removedFolders = lastSnapshot.filter { folder -> currentSnapshot.none { it.uri == folder.uri } }
+
+ if (addedFolders.isNotEmpty() || removedFolders.isNotEmpty()) {
+ languageServer.workspaceService.didChangeWorkspaceFolders(
+ DidChangeWorkspaceFoldersParams().apply {
+ this.event = WorkspaceFoldersChangeEvent().apply {
+ added = addedFolders
+ removed = removedFolders
+ }
+ }
+ )
+ }
+
+ lastSnapshot = currentSnapshot
+ }
+ }
+
+ private fun shouldHandleFile(file: VirtualFile): Boolean {
+ if (file.isDirectory) {
+ return true // Matches "**/*" with matches: "folder"
+ }
+ val path = Paths.get(file.path)
+ val result = supportedFilePatterns.matches(path)
+ return result
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtilTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtilTest.kt
new file mode 100644
index 00000000000..962d55c955e
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/WorkspaceFolderUtilTest.kt
@@ -0,0 +1,64 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.util
+
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.roots.ProjectRootManager
+import com.intellij.openapi.vfs.VirtualFile
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class WorkspaceFolderUtilTest {
+
+ @Test
+ fun `createWorkspaceFolders returns empty list when no workspace folders`() {
+ val mockProject = mockk()
+ every { mockProject.isDefault } returns true
+
+ val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject)
+
+ assertEquals(emptyList(), result)
+ }
+
+ @Test
+ fun `createWorkspaceFolders returns workspace folders for non-default project`() {
+ val mockProject = mockk()
+ val mockProjectRootManager = mockk()
+ val mockContentRoot1 = mockk()
+ val mockContentRoot2 = mockk()
+
+ every { mockProject.isDefault } returns false
+ every { ProjectRootManager.getInstance(mockProject) } returns mockProjectRootManager
+ every { mockProjectRootManager.contentRoots } returns arrayOf(mockContentRoot1, mockContentRoot2)
+
+ every { mockContentRoot1.name } returns "root1"
+ every { mockContentRoot1.url } returns "file:///path/to/root1"
+ every { mockContentRoot2.name } returns "root2"
+ every { mockContentRoot2.url } returns "file:///path/to/root2"
+
+ val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject)
+
+ assertEquals(2, result.size)
+ assertEquals("file:///path/to/root1", result[0].uri)
+ assertEquals("file:///path/to/root2", result[1].uri)
+ assertEquals("root1", result[0].name)
+ assertEquals("root2", result[1].name)
+ }
+
+ @Test
+ fun `reateWorkspaceFolders returns empty list when project has no content roots`() {
+ val mockProject = mockk()
+ val mockProjectRootManager = mockk()
+
+ every { mockProject.isDefault } returns false
+ every { ProjectRootManager.getInstance(mockProject) } returns mockProjectRootManager
+ every { mockProjectRootManager.contentRoots } returns emptyArray()
+
+ val result = WorkspaceFolderUtil.createWorkspaceFolders(mockProject)
+
+ assertEquals(emptyList(), result)
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt
new file mode 100644
index 00000000000..adb9e105f2c
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt
@@ -0,0 +1,560 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace
+
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.application.Application
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.components.serviceIfCreated
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.openapi.vfs.newvfs.events.VFileCreateEvent
+import com.intellij.openapi.vfs.newvfs.events.VFileDeleteEvent
+import com.intellij.openapi.vfs.newvfs.events.VFileEvent
+import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent
+import com.intellij.util.messages.MessageBus
+import com.intellij.util.messages.MessageBusConnection
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.runs
+import io.mockk.slot
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import org.eclipse.lsp4j.CreateFilesParams
+import org.eclipse.lsp4j.DeleteFilesParams
+import org.eclipse.lsp4j.DidChangeWatchedFilesParams
+import org.eclipse.lsp4j.DidChangeWorkspaceFoldersParams
+import org.eclipse.lsp4j.FileChangeType
+import org.eclipse.lsp4j.RenameFilesParams
+import org.eclipse.lsp4j.WorkspaceFolder
+import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage
+import org.eclipse.lsp4j.services.WorkspaceService
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLanguageServer
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil
+import java.net.URI
+import java.nio.file.Path
+import java.util.concurrent.Callable
+import java.util.concurrent.CompletableFuture
+
+class WorkspaceServiceHandlerTest {
+ private lateinit var project: Project
+ private lateinit var mockLanguageServer: AmazonQLanguageServer
+ private lateinit var mockWorkspaceService: WorkspaceService
+ private lateinit var sut: WorkspaceServiceHandler
+ private lateinit var mockApplication: Application
+
+ @BeforeEach
+ fun setup() {
+ project = mockk()
+ mockWorkspaceService = mockk()
+ mockLanguageServer = mockk()
+
+ mockApplication = mockk()
+ mockkStatic(ApplicationManager::class)
+ every { ApplicationManager.getApplication() } returns mockApplication
+ every { mockApplication.executeOnPooledThread(any>()) } answers {
+ CompletableFuture.completedFuture(firstArg>().call())
+ }
+
+ // Mock the LSP service
+ val mockLspService = mockk()
+
+ // Mock the service methods on Project
+ every { project.getService(AmazonQLspService::class.java) } returns mockLspService
+ every { project.serviceIfCreated() } returns mockLspService
+
+ // Mock the LSP service's executeSync method as a suspend function
+ every {
+ mockLspService.executeSync>(any())
+ } coAnswers {
+ val func = firstArg CompletableFuture>()
+ func.invoke(mockLanguageServer)
+ }
+
+ // Mock workspace service
+ every { mockLanguageServer.workspaceService } returns mockWorkspaceService
+ every { mockWorkspaceService.didCreateFiles(any()) } returns Unit
+ every { mockWorkspaceService.didDeleteFiles(any()) } returns Unit
+ every { mockWorkspaceService.didRenameFiles(any()) } returns Unit
+ every { mockWorkspaceService.didChangeWatchedFiles(any()) } returns Unit
+ every { mockWorkspaceService.didChangeWorkspaceFolders(any()) } returns Unit
+
+ // Mock message bus
+ val messageBus = mockk()
+ every { project.messageBus } returns messageBus
+ val mockConnection = mockk()
+ every { messageBus.connect(any()) } returns mockConnection
+ every { mockConnection.subscribe(any(), any()) } just runs
+
+ sut = WorkspaceServiceHandler(project, mockk())
+ }
+
+ @Test
+ fun `test didCreateFiles with Python file`() = runTest {
+ val pyUri = URI("file:///test/path")
+ val pyEvent = createMockVFileEvent(pyUri, FileChangeType.Created, false, "py")
+
+ sut.after(listOf(pyEvent))
+
+ val paramsSlot = slot()
+ verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) }
+ assertEquals(pyUri.toString(), paramsSlot.captured.files[0].uri)
+ }
+
+ @Test
+ fun `test didCreateFiles with TypeScript file`() = runTest {
+ val tsUri = URI("file:///test/path")
+ val tsEvent = createMockVFileEvent(tsUri, FileChangeType.Created, false, "ts")
+
+ sut.after(listOf(tsEvent))
+
+ val paramsSlot = slot()
+ verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) }
+ assertEquals(tsUri.toString(), paramsSlot.captured.files[0].uri)
+ }
+
+ @Test
+ fun `test didCreateFiles with JavaScript file`() = runTest {
+ val jsUri = URI("file:///test/path")
+ val jsEvent = createMockVFileEvent(jsUri, FileChangeType.Created, false, "js")
+
+ sut.after(listOf(jsEvent))
+
+ val paramsSlot = slot()
+ verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) }
+ assertEquals(jsUri.toString(), paramsSlot.captured.files[0].uri)
+ }
+
+ @Test
+ fun `test didCreateFiles with Java file`() = runTest {
+ val javaUri = URI("file:///test/path")
+ val javaEvent = createMockVFileEvent(javaUri, FileChangeType.Created, false, "java")
+
+ sut.after(listOf(javaEvent))
+
+ val paramsSlot = slot()
+ verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) }
+ assertEquals(javaUri.toString(), paramsSlot.captured.files[0].uri)
+ }
+
+ @Test
+ fun `test didCreateFiles called for directory`() = runTest {
+ val dirUri = URI("file:///test/directory/path")
+ val dirEvent = createMockVFileEvent(dirUri, FileChangeType.Created, true, "")
+
+ sut.after(listOf(dirEvent))
+
+ val paramsSlot = slot()
+ verify { mockWorkspaceService.didCreateFiles(capture(paramsSlot)) }
+ assertEquals(dirUri.toString(), paramsSlot.captured.files[0].uri)
+ }
+
+ @Test
+ fun `test didCreateFiles not called for unsupported file extension`() = runTest {
+ val txtUri = URI("file:///test/path")
+ val txtEvent = createMockVFileEvent(txtUri, FileChangeType.Created, false, "txt")
+
+ sut.after(listOf(txtEvent))
+
+ verify(exactly = 0) { mockWorkspaceService.didCreateFiles(any()) }
+ }
+
+ @Test
+ fun `test didDeleteFiles with Python file`() = runTest {
+ val pyUri = URI("file:///test/path")
+ val pyEvent = createMockVFileEvent(pyUri, FileChangeType.Deleted, false, "py")
+
+ sut.after(listOf(pyEvent))
+
+ val paramsSlot = slot()
+ verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) }
+ assertEquals(pyUri.toString(), paramsSlot.captured.files[0].uri)
+ }
+
+ @Test
+ fun `test didDeleteFiles with TypeScript file`() = runTest {
+ val tsUri = URI("file:///test/path")
+ val tsEvent = createMockVFileEvent(tsUri, FileChangeType.Deleted, false, "ts")
+
+ sut.after(listOf(tsEvent))
+
+ val paramsSlot = slot()
+ verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) }
+ assertEquals(tsUri.toString(), paramsSlot.captured.files[0].uri)
+ }
+
+ @Test
+ fun `test didDeleteFiles with JavaScript file`() = runTest {
+ val jsUri = URI("file:///test/path")
+ val jsEvent = createMockVFileEvent(jsUri, FileChangeType.Deleted, false, "js")
+
+ sut.after(listOf(jsEvent))
+
+ val paramsSlot = slot()
+ verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) }
+ assertEquals(jsUri.toString(), paramsSlot.captured.files[0].uri)
+ }
+
+ @Test
+ fun `test didDeleteFiles with Java file`() = runTest {
+ val javaUri = URI("file:///test/path")
+ val javaEvent = createMockVFileEvent(javaUri, FileChangeType.Deleted, false, "java")
+
+ sut.after(listOf(javaEvent))
+
+ val paramsSlot = slot()
+ verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) }
+ assertEquals(javaUri.toString(), paramsSlot.captured.files[0].uri)
+ }
+
+ @Test
+ fun `test didDeleteFiles not called for unsupported file extension`() = runTest {
+ val txtUri = URI("file:///test/path")
+ val txtEvent = createMockVFileEvent(txtUri, FileChangeType.Deleted, false, "txt")
+
+ sut.after(listOf(txtEvent))
+
+ verify(exactly = 0) { mockWorkspaceService.didDeleteFiles(any()) }
+ }
+
+ @Test
+ fun `test didDeleteFiles called for directory`() = runTest {
+ val dirUri = URI("file:///test/directory/path")
+ val dirEvent = createMockVFileEvent(dirUri, FileChangeType.Deleted, true, "")
+
+ sut.after(listOf(dirEvent))
+
+ val paramsSlot = slot()
+ verify { mockWorkspaceService.didDeleteFiles(capture(paramsSlot)) }
+ assertEquals(dirUri.toString(), paramsSlot.captured.files[0].uri)
+ }
+
+ @Test
+ fun `test didChangeWatchedFiles with valid events`() = runTest {
+ // Arrange
+ val createURI = URI("file:///test/pathOfCreation")
+ val deleteURI = URI("file:///test/pathOfDeletion")
+ val changeURI = URI("file:///test/pathOfChange")
+
+ val virtualFileCreate = createMockVFileEvent(createURI, FileChangeType.Created, false)
+ val virtualFileDelete = createMockVFileEvent(deleteURI, FileChangeType.Deleted, false)
+ val virtualFileChange = createMockVFileEvent(changeURI, FileChangeType.Changed, false)
+
+ // Act
+ sut.after(listOf(virtualFileCreate, virtualFileDelete, virtualFileChange))
+
+ // Assert
+ val paramsSlot = slot()
+ verify { mockWorkspaceService.didChangeWatchedFiles(capture(paramsSlot)) }
+ assertEquals(createURI.toString(), paramsSlot.captured.changes[0].uri)
+ assertEquals(FileChangeType.Created, paramsSlot.captured.changes[0].type)
+ assertEquals(deleteURI.toString(), paramsSlot.captured.changes[1].uri)
+ assertEquals(FileChangeType.Deleted, paramsSlot.captured.changes[1].type)
+ assertEquals(changeURI.toString(), paramsSlot.captured.changes[2].uri)
+ assertEquals(FileChangeType.Changed, paramsSlot.captured.changes[2].type)
+ }
+
+ @Test
+ fun `test no invoked messages when events are empty`() = runTest {
+ // Act
+ sut.after(emptyList())
+
+ // Assert
+ verify(exactly = 0) { mockWorkspaceService.didCreateFiles(any()) }
+ verify(exactly = 0) { mockWorkspaceService.didDeleteFiles(any()) }
+ verify(exactly = 0) { mockWorkspaceService.didChangeWatchedFiles(any()) }
+ }
+
+ @Test
+ fun `test didRenameFiles with supported file`() = runTest {
+ // Arrange
+ val oldName = "oldFile.java"
+ val newName = "newFile.java"
+ val propertyEvent = createMockPropertyChangeEvent(
+ oldName = oldName,
+ newName = newName,
+ isDirectory = false,
+ )
+
+ // Act
+ sut.after(listOf(propertyEvent))
+
+ // Assert
+ val paramsSlot = slot()
+ verify { mockWorkspaceService.didRenameFiles(capture(paramsSlot)) }
+ with(paramsSlot.captured.files[0]) {
+ assertEquals("file:///test/$oldName", oldUri)
+ assertEquals("file:///test/$newName", newUri)
+ }
+ }
+
+ @Test
+ fun `test didRenameFiles with unsupported file type`() = runTest {
+ // Arrange
+ val propertyEvent = createMockPropertyChangeEvent(
+ oldName = "oldFile.txt",
+ newName = "newFile.txt",
+ isDirectory = false,
+ )
+
+ // Act
+ sut.after(listOf(propertyEvent))
+
+ // Assert
+ verify(exactly = 0) { mockWorkspaceService.didRenameFiles(any()) }
+ }
+
+ @Test
+ fun `test didRenameFiles with directory`() = runTest {
+ // Arrange
+ val propertyEvent = createMockPropertyChangeEvent(
+ oldName = "oldDir",
+ newName = "newDir",
+ isDirectory = true
+ )
+
+ // Act
+ sut.after(listOf(propertyEvent))
+
+ // Assert
+ val paramsSlot = slot()
+ verify { mockWorkspaceService.didRenameFiles(capture(paramsSlot)) }
+ with(paramsSlot.captured.files[0]) {
+ assertEquals("file:///test/oldDir", oldUri)
+ assertEquals("file:///test/newDir", newUri)
+ }
+ }
+
+ @Test
+ fun `test didRenameFiles with multiple files`() = runTest {
+ // Arrange
+ val event1 = createMockPropertyChangeEvent(
+ oldName = "old1.java",
+ newName = "new1.java",
+ )
+ val event2 = createMockPropertyChangeEvent(
+ oldName = "old2.py",
+ newName = "new2.py",
+ )
+
+ // Act
+ sut.after(listOf(event1, event2))
+
+ // Assert
+ val paramsSlot = slot()
+ verify { mockWorkspaceService.didRenameFiles(capture(paramsSlot)) }
+ assertEquals(2, paramsSlot.captured.files.size)
+ }
+
+ @Test
+ fun `rootsChanged does not notify when no changes`() = runTest {
+ // Arrange
+ mockkObject(WorkspaceFolderUtil)
+ val folders = listOf(
+ WorkspaceFolder().apply {
+ name = "folder1"
+ uri = "file:///path/to/folder1"
+ }
+ )
+ every { WorkspaceFolderUtil.createWorkspaceFolders(any()) } returns folders
+
+ // Act
+ sut.beforeRootsChange(mockk())
+ sut.rootsChanged(mockk())
+
+ // Assert
+ verify(exactly = 0) { mockWorkspaceService.didChangeWorkspaceFolders(any()) }
+ }
+
+ // rootsChanged handles
+ @Test
+ fun `rootsChanged handles init`() = runTest {
+ // Arrange
+ mockkObject(WorkspaceFolderUtil)
+ val oldFolders = emptyList()
+ val newFolders = listOf(
+ WorkspaceFolder().apply {
+ name = "folder1"
+ uri = "file:///path/to/folder1"
+ }
+ )
+
+ // Act
+ every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns oldFolders
+ sut.beforeRootsChange(mockk())
+ every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns newFolders
+ sut.rootsChanged(mockk())
+
+ // Assert
+ val paramsSlot = slot()
+ verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) }
+ assertEquals(1, paramsSlot.captured.event.added.size)
+ assertEquals("folder1", paramsSlot.captured.event.added[0].name)
+ }
+
+ // rootsChanged handles additional files added to root
+ @Test
+ fun `rootsChanged handles additional files added to root`() = runTest {
+ // Arrange
+ mockkObject(WorkspaceFolderUtil)
+ val oldFolders = listOf(
+ WorkspaceFolder().apply {
+ name = "folder1"
+ uri = "file:///path/to/folder1"
+ }
+ )
+ val newFolders = listOf(
+ WorkspaceFolder().apply {
+ name = "folder1"
+ uri = "file:///path/to/folder1"
+ },
+ WorkspaceFolder().apply {
+ name = "folder2"
+ uri = "file:///path/to/folder2"
+ }
+ )
+
+ // Act
+ every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns oldFolders
+ sut.beforeRootsChange(mockk())
+ every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns newFolders
+ sut.rootsChanged(mockk())
+
+ // Assert
+ val paramsSlot = slot()
+ verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) }
+ assertEquals(1, paramsSlot.captured.event.added.size)
+ assertEquals("folder2", paramsSlot.captured.event.added[0].name)
+ }
+
+ // rootsChanged handles removal of files from root
+ @Test
+ fun `rootsChanged handles removal of files from root`() = runTest {
+ // Arrange
+ mockkObject(WorkspaceFolderUtil)
+ val oldFolders = listOf(
+ WorkspaceFolder().apply {
+ name = "folder1"
+ uri = "file:///path/to/folder1"
+ },
+ WorkspaceFolder().apply {
+ name = "folder2"
+ uri = "file:///path/to/folder2"
+ }
+ )
+ val newFolders = listOf(
+ WorkspaceFolder().apply {
+ name = "folder1"
+ uri = "file:///path/to/folder1"
+ }
+ )
+
+ // Act
+ every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns oldFolders
+ sut.beforeRootsChange(mockk())
+ every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns newFolders
+ sut.rootsChanged(mockk())
+
+ // Assert
+ val paramsSlot = slot()
+ verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) }
+ assertEquals(1, paramsSlot.captured.event.removed.size)
+ assertEquals("folder2", paramsSlot.captured.event.removed[0].name)
+ }
+
+ @Test
+ fun `rootsChanged handles multiple simultaneous additions and removals`() = runTest {
+ // Arrange
+ mockkObject(WorkspaceFolderUtil)
+ val oldFolders = listOf(
+ WorkspaceFolder().apply {
+ name = "folder1"
+ uri = "file:///path/to/folder1"
+ },
+ WorkspaceFolder().apply {
+ name = "folder2"
+ uri = "file:///path/to/folder2"
+ }
+ )
+ val newFolders = listOf(
+ WorkspaceFolder().apply {
+ name = "folder1"
+ uri = "file:///path/to/folder1"
+ },
+ WorkspaceFolder().apply {
+ name = "folder3"
+ uri = "file:///path/to/folder3"
+ }
+ )
+
+ // Act
+ every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns oldFolders
+ sut.beforeRootsChange(mockk())
+ every { WorkspaceFolderUtil.createWorkspaceFolders(project) } returns newFolders
+ sut.rootsChanged(mockk())
+
+ // Assert
+ val paramsSlot = slot()
+ verify(exactly = 1) { mockWorkspaceService.didChangeWorkspaceFolders(capture(paramsSlot)) }
+ assertEquals(1, paramsSlot.captured.event.added.size)
+ assertEquals(1, paramsSlot.captured.event.removed.size)
+ assertEquals("folder3", paramsSlot.captured.event.added[0].name)
+ assertEquals("folder2", paramsSlot.captured.event.removed[0].name)
+ }
+
+ private fun createMockVFileEvent(uri: URI, type: FileChangeType = FileChangeType.Changed, isDirectory: Boolean, extension: String = "py"): VFileEvent {
+ val virtualFile = mockk()
+ val nioPath = mockk()
+
+ every { virtualFile.isDirectory } returns isDirectory
+ every { virtualFile.toNioPath() } returns nioPath
+ every { nioPath.toUri() } returns uri
+ every { virtualFile.path } returns "${uri.path}.$extension"
+
+ return when (type) {
+ FileChangeType.Deleted -> mockk()
+ FileChangeType.Created -> mockk()
+ else -> mockk()
+ }.apply {
+ every { file } returns virtualFile
+ }
+ }
+
+ // for didRename events
+ private fun createMockPropertyChangeEvent(
+ oldName: String,
+ newName: String,
+ isDirectory: Boolean = false,
+ ): VFilePropertyChangeEvent {
+ val file = mockk()
+ val parent = mockk()
+ val parentPath = mockk()
+ val filePath = mockk()
+
+ every { file.parent } returns parent
+ every { parent.toNioPath() } returns parentPath
+ every { file.toNioPath() } returns filePath
+ every { file.isDirectory } returns isDirectory
+ every { file.path } returns "/test/$newName"
+
+ every { parentPath.resolve(oldName) } returns mockk {
+ every { toUri() } returns URI("file:///test/$oldName")
+ }
+ every { filePath.toUri() } returns URI("file:///test/$newName")
+
+ return mockk().apply {
+ every { propertyName } returns VirtualFile.PROP_NAME
+ every { this@apply.file } returns file
+ every { oldValue } returns oldName
+ every { newValue } returns newName
+ }
+ }
+}
From e20697a53b8efbde321840691d7c56bd9acc37ee Mon Sep 17 00:00:00 2001
From: Richard Li <742829+rli@users.noreply.github.com>
Date: Mon, 3 Mar 2025 15:13:46 -0800
Subject: [PATCH 017/117] fix(amazonq): fix failing lsp artifact tests (#5433)
Tests were not working correctly due to dependency on application, but tests did not declare usage of ApplicationExtension
---
gradle/libs.versions.toml | 3 +-
.../amazonq/lsp/artifacts/ArtifactHelper.kt | 7 ++-
.../amazonq/lsp/artifacts/ManifestFetcher.kt | 6 ++-
.../lsp/artifacts/ArtifactHelperTest.kt | 5 +-
.../lsp/artifacts/ManifestFetcherTest.kt | 46 ++++++++++++++-----
5 files changed, 49 insertions(+), 18 deletions(-)
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 00c76551542..715bbe261de 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -106,6 +106,7 @@ kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref =
kotlin-stdLibJdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
+mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" }
mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlin" }
mockk = { module = "io.mockk:mockk", version.ref="mockk" }
nimbus-jose-jwt = {module = "com.nimbusds:nimbus-jose-jwt", version.ref = "nimbus-jose-jwt"}
@@ -121,7 +122,7 @@ zjsonpatch = { module = "com.flipkart.zjsonpatch:zjsonpatch", version.ref = "zjs
[bundles]
jackson = ["jackson-datetime", "jackson-kotlin", "jackson-yaml", "jackson-xml"]
kotlin = ["kotlin-stdLibJdk8", "kotlin-reflect"]
-mockito = ["mockito-core", "mockito-kotlin"]
+mockito = ["mockito-core", "mockito-junit-jupiter", "mockito-kotlin"]
sshd = ["sshd-core", "sshd-scp", "sshd-sftp"]
[plugins]
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt
index e0fe212b9d4..8787259bf08 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelper.kt
@@ -112,7 +112,7 @@ class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH,
logger.info { "Attempt ${currentAttempt.get()} of $maxDownloadAttempts to download LSP artifacts" }
try {
- withBackgroundProgress(
+ return withBackgroundProgress(
project,
AwsCoreBundle.message("amazonqFeatureDev.placeholder.downloading_and_extracting_lsp_artifacts"),
cancellable = true
@@ -123,9 +123,12 @@ class ArtifactHelper(private val lspArtifactsPath: Path = DEFAULT_ARTIFACT_PATH,
.mapNotNull { it.filename }
.forEach { filename -> extractZipFile(downloadPath.resolve(filename), downloadPath) }
logger.info { "Successfully downloaded and moved LSP artifacts to $downloadPath" }
+
+ return@withBackgroundProgress downloadPath
}
+
+ return@withBackgroundProgress null
}
- return downloadPath
} catch (e: Exception) {
when (e) {
is CancellationException -> {
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt
index 92d0f8d9e98..4ccfbeca491 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcher.kt
@@ -19,7 +19,7 @@ import java.nio.file.Path
class ManifestFetcher(
private val lspManifestUrl: String = DEFAULT_MANIFEST_URL,
private val manifestManager: ManifestManager = ManifestManager(),
- private val lspManifestFilePath: Path = DEFAULT_MANIFEST_PATH,
+ private val manifestPath: Path = DEFAULT_MANIFEST_PATH,
) {
companion object {
private val logger = getLogger()
@@ -34,6 +34,10 @@ class ManifestFetcher(
.resolve("jetbrains-lsp-manifest.json")
}
+ @get:VisibleForTesting
+ internal val lspManifestFilePath: Path
+ get() = manifestPath
+
/**
* Method which will be used to fetch latest manifest.
* */
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt
index cf0dc137901..5fede85b27e 100644
--- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ArtifactHelperTest.kt
@@ -4,6 +4,7 @@
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
import com.intellij.openapi.project.Project
+import com.intellij.testFramework.ApplicationExtension
import com.intellij.util.io.createDirectories
import com.intellij.util.text.SemVer
import io.mockk.Runs
@@ -14,16 +15,16 @@ import io.mockk.mockkStatic
import io.mockk.spyk
import kotlinx.coroutines.runBlocking
import org.assertj.core.api.Assertions.assertThat
-import org.jetbrains.annotations.TestOnly
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.io.TempDir
import org.mockito.kotlin.mock
import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager.SupportedManifestVersionRange
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
import java.nio.file.Path
-@TestOnly
+@ExtendWith(ApplicationExtension::class)
class ArtifactHelperTest {
@TempDir
lateinit var tempDir: Path
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt
index 62e17089eef..b5a1bd32fac 100644
--- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/artifacts/ManifestFetcherTest.kt
@@ -3,22 +3,28 @@
package software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts
+import com.intellij.testFramework.ApplicationExtension
+import com.intellij.testFramework.utils.io.createFile
import io.mockk.every
+import io.mockk.junit5.MockKExtension
import io.mockk.mockkStatic
import org.assertj.core.api.Assertions.assertThat
-import org.jetbrains.annotations.TestOnly
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import org.junit.jupiter.api.io.TempDir
+import org.mockito.junit.jupiter.MockitoExtension
import org.mockito.kotlin.atLeastOnce
import org.mockito.kotlin.never
-import org.mockito.kotlin.reset
import org.mockito.kotlin.spy
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import software.aws.toolkits.jetbrains.core.getTextFromUrl
import software.aws.toolkits.jetbrains.services.amazonq.project.manifest.ManifestManager
+import java.nio.file.Path
+import java.nio.file.Paths
-@TestOnly
+@ExtendWith(ApplicationExtension::class, MockitoExtension::class, MockKExtension::class)
class ManifestFetcherTest {
private lateinit var manifestFetcher: ManifestFetcher
@@ -42,7 +48,6 @@ class ManifestFetcherTest {
@Test
fun `should return valid result from local should not execute remote method`() {
- reset(manifestFetcher)
whenever(manifestFetcher.fetchManifestFromLocal()).thenReturn(manifest)
assertThat(manifestFetcher.fetch()).isNotNull().isEqualTo(manifest)
@@ -65,8 +70,6 @@ class ManifestFetcherTest {
mockkStatic("software.aws.toolkits.jetbrains.core.HttpUtilsKt")
every { getTextFromUrl(any()) } returns "ManifestContent"
- whenever(manifestManager.readManifestFile("")).thenReturn(null)
-
assertThat(manifestFetcher.fetchManifestFromRemote()).isNull()
}
@@ -84,17 +87,36 @@ class ManifestFetcherTest {
@Test
fun `fetchManifestFromRemote should return null if manifest is deprecated`() {
mockkStatic("software.aws.toolkits.jetbrains.core.HttpUtilsKt")
- every { getTextFromUrl(any()) } returns "ManifestContent"
-
- val deprecatedManifest = ManifestManager.Manifest(isManifestDeprecated = true)
-
- whenever(manifestManager.readManifestFile("")).thenReturn(deprecatedManifest)
+ every { getTextFromUrl(any()) } returns
+ // language=JSON
+ """
+ {
+ "manifestSchemaVersion": "1.0",
+ "isManifestDeprecated": true
+ }
+ """.trimIndent()
assertThat(manifestFetcher.fetchManifestFromRemote()).isNull()
}
@Test
- fun `fetchManifestFromLocal should return null`() {
+ fun `fetchManifestFromLocal should return null if path does not exist locally`() {
+ whenever(manifestFetcher.lspManifestFilePath).thenReturn(Paths.get("does", "not", "exist"))
+ assertThat(manifestFetcher.fetchManifestFromLocal()).isNull()
+ }
+
+ @Test
+ fun `fetchManifestFromLocal should return local path if exists locally`(@TempDir tempDir: Path) {
+ val manifestFile = tempDir.createFile("manifest.json")
+ manifestFile.toFile().writeText(
+ // language=JSON
+ """
+ {
+ "manifestSchemaVersion": "1.0"
+ }
+ """.trimIndent()
+ )
+ whenever(manifestFetcher.lspManifestFilePath).thenReturn(manifestFile)
assertThat(manifestFetcher.fetchManifestFromLocal()).isNull()
}
}
From 9574b4295a7c1c84d56dda7f1b01e29bf980119d Mon Sep 17 00:00:00 2001
From: Richard Li <742829+rli@users.noreply.github.com>
Date: Tue, 4 Mar 2025 10:06:26 -0800
Subject: [PATCH 018/117] feat(amazonq): expose lsp server capabilities to
consumers of AmazonQLspService (#5417)
For certain capabilities, the client implementation needs to be aware of what resources the server is interested in.
In the case of `WorkspaceEdit`, we need to expose the filters returned by the server in the initialization handshake.
```json
"fileOperations": {
"didCreate": {
"filters": [
{
"pattern": {
"glob": "**/*.{ts,js,py,java}",
"matches": "file"
}
},
{
"pattern": {
"glob": "**/*",
"matches": "folder"
}
}
]
},
"didRename": {
"filters": [
{
"pattern": {
"glob": "**/*.{ts,js,py,java}",
"matches": "file"
}
},
{
"pattern": {
"glob": "**/*",
"matches": "folder"
}
}
]
},
"didDelete": {
"filters": [
{
"pattern": {
"glob": "**/*.{ts,js,py,java}",
"matches": "file"
}
},
{
"pattern": {
"glob": "**/*",
"matches": "folder"
}
}
]
}
}
```
---
.../services/amazonq/lsp/AmazonQLspService.kt | 24 +++++++++++--------
.../auth/DefaultAuthCredentialsServiceTest.kt | 4 ++--
.../workspace/WorkspaceServiceHandlerTest.kt | 4 ++--
3 files changed, 18 insertions(+), 14 deletions(-)
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
index 0d774e5069a..c19538fbc2b 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
@@ -21,10 +21,8 @@ import com.intellij.openapi.util.Key
import com.intellij.util.io.await
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
-import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -33,6 +31,7 @@ import org.eclipse.lsp4j.ClientCapabilities
import org.eclipse.lsp4j.ClientInfo
import org.eclipse.lsp4j.FileOperationsWorkspaceCapabilities
import org.eclipse.lsp4j.InitializeParams
+import org.eclipse.lsp4j.InitializeResult
import org.eclipse.lsp4j.InitializedParams
import org.eclipse.lsp4j.SynchronizationCapabilities
import org.eclipse.lsp4j.TextDocumentClientCapabilities
@@ -96,6 +95,8 @@ internal class LSPProcessListener : ProcessListener {
@Service(Service.Level.PROJECT)
class AmazonQLspService(private val project: Project, private val cs: CoroutineScope) : Disposable {
private var instance: Deferred
+ val capabilities
+ get() = instance.getCompleted().initializeResult.getCompleted().capabilities
// dont allow lsp commands if server is restarting
private val mutex = Mutex(false)
@@ -111,7 +112,7 @@ class AmazonQLspService(private val project: Project, private val cs: CoroutineS
Disposer.register(this@AmazonQLspService, it)
}
// wait for handshake to complete
- instance.initializer.join()
+ instance.initializeResult.join()
instance
}
@@ -141,7 +142,7 @@ class AmazonQLspService(private val project: Project, private val cs: CoroutineS
try {
val i = it.await()
- if (i.initializer.isActive) {
+ if (i.initializeResult.isActive) {
// not initialized
return
}
@@ -155,17 +156,17 @@ class AmazonQLspService(private val project: Project, private val cs: CoroutineS
instance = start()
}
- suspend fun execute(runnable: suspend (AmazonQLanguageServer) -> T): T {
+ suspend fun execute(runnable: suspend AmazonQLspService.(AmazonQLanguageServer) -> T): T {
val lsp = withTimeout(10.seconds) {
val holder = mutex.withLock { instance }.await()
- holder.initializer.join()
+ holder.initializeResult.join()
holder.languageServer
}
return runnable(lsp)
}
- fun executeSync(runnable: suspend (AmazonQLanguageServer) -> T): T =
+ fun executeSync(runnable: suspend AmazonQLspService.(AmazonQLanguageServer) -> T): T =
runBlocking(cs.coroutineContext) {
execute(runnable)
}
@@ -174,7 +175,7 @@ class AmazonQLspService(private val project: Project, private val cs: CoroutineS
private val LOG = getLogger()
fun getInstance(project: Project) = project.service()
- fun executeIfRunning(project: Project, runnable: (AmazonQLanguageServer) -> T): T? =
+ fun executeIfRunning(project: Project, runnable: AmazonQLspService.(AmazonQLanguageServer) -> T): T? =
project.serviceIfCreated()?.executeSync(runnable)
}
}
@@ -190,7 +191,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs
@Suppress("ForbiddenVoid")
private val launcherFuture: Future
private val launcherHandler: KillableProcessHandler
- val initializer: Job
+ val initializeResult: Deferred
private fun createClientCapabilities(): ClientCapabilities =
ClientCapabilities().apply {
@@ -272,7 +273,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs
launcherFuture = launcher.startListening()
- initializer = cs.launch {
+ initializeResult = cs.async {
// encryption info must be sent within 5s or Flare process will exit
encryptionManager.writeInitializationPayload(launcherHandler.process.outputStream)
@@ -291,8 +292,11 @@ private class AmazonQServerInstance(private val project: Project, private val cs
// then if this succeeds then we can allow the client to send requests
if (initializeResult == null) {
launcherHandler.destroyProcess()
+ error("LSP initialization failed")
}
languageServer.initialized(InitializedParams())
+
+ initializeResult
}
DefaultAuthCredentialsService(project, encryptionManager, this)
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt
index 95d5f6e38d8..388e2286082 100644
--- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/auth/DefaultAuthCredentialsServiceTest.kt
@@ -43,8 +43,8 @@ class DefaultAuthCredentialsServiceTest {
every {
mockLspService.executeSync>(any())
} coAnswers {
- val func = firstArg CompletableFuture>()
- func.invoke(mockLanguageServer)
+ val func = firstArg CompletableFuture>()
+ func.invoke(mockLspService, mockLanguageServer)
}
// Mock message bus
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt
index adb9e105f2c..66315a9d059 100644
--- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/workspace/WorkspaceServiceHandlerTest.kt
@@ -75,8 +75,8 @@ class WorkspaceServiceHandlerTest {
every {
mockLspService.executeSync>(any())
} coAnswers {
- val func = firstArg CompletableFuture>()
- func.invoke(mockLanguageServer)
+ val func = firstArg CompletableFuture>()
+ func.invoke(mockLspService, mockLanguageServer)
}
// Mock workspace service
From 6df31d948d00cac4415380e091c961faebd330cf Mon Sep 17 00:00:00 2001
From: Sam Stewart
Date: Tue, 4 Mar 2025 10:07:32 -0800
Subject: [PATCH 019/117] feat(amazonq): implement TextDocumentService message
handler (#5380)
initial framework for TextDocumentService message implementations
---
.../services/amazonq/lsp/AmazonQLspService.kt | 2 +
.../TextDocumentServiceHandler.kt | 131 ++++++++
.../TextDocumentServiceHandlerTest.kt | 301 ++++++++++++++++++
3 files changed, 434 insertions(+)
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
index c19538fbc2b..501d93e78e8 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
@@ -46,6 +46,7 @@ import software.aws.toolkits.jetbrains.isDeveloperMode
import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService
import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.createExtendedClientMetadata
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument.TextDocumentServiceHandler
import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.WorkspaceFolderUtil.createWorkspaceFolders
import software.aws.toolkits.jetbrains.services.amazonq.lsp.workspace.WorkspaceServiceHandler
import software.aws.toolkits.jetbrains.services.telemetry.ClientMetadata
@@ -300,6 +301,7 @@ private class AmazonQServerInstance(private val project: Project, private val cs
}
DefaultAuthCredentialsService(project, encryptionManager, this)
+ TextDocumentServiceHandler(project, this)
WorkspaceServiceHandler(project, this)
}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt
new file mode 100644
index 00000000000..4cda131efe6
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt
@@ -0,0 +1,131 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument
+
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.editor.Document
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.openapi.fileEditor.FileDocumentManagerListener
+import com.intellij.openapi.fileEditor.FileEditorManager
+import com.intellij.openapi.fileEditor.FileEditorManagerListener
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.openapi.vfs.VirtualFileManager
+import com.intellij.openapi.vfs.newvfs.BulkFileListener
+import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent
+import com.intellij.openapi.vfs.newvfs.events.VFileEvent
+import org.eclipse.lsp4j.DidChangeTextDocumentParams
+import org.eclipse.lsp4j.DidCloseTextDocumentParams
+import org.eclipse.lsp4j.DidOpenTextDocumentParams
+import org.eclipse.lsp4j.DidSaveTextDocumentParams
+import org.eclipse.lsp4j.TextDocumentContentChangeEvent
+import org.eclipse.lsp4j.TextDocumentIdentifier
+import org.eclipse.lsp4j.TextDocumentItem
+import org.eclipse.lsp4j.VersionedTextDocumentIdentifier
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService
+import software.aws.toolkits.jetbrains.utils.pluginAwareExecuteOnPooledThread
+
+class TextDocumentServiceHandler(
+ private val project: Project,
+ serverInstance: Disposable,
+) : FileDocumentManagerListener,
+ FileEditorManagerListener,
+ BulkFileListener {
+
+ init {
+ // didOpen & didClose events
+ project.messageBus.connect(serverInstance).subscribe(
+ FileEditorManagerListener.FILE_EDITOR_MANAGER,
+ this
+ )
+
+ // didChange events
+ project.messageBus.connect(serverInstance).subscribe(
+ VirtualFileManager.VFS_CHANGES,
+ this
+ )
+
+ // didSave events
+ project.messageBus.connect(serverInstance).subscribe(
+ FileDocumentManagerListener.TOPIC,
+ this
+ )
+ }
+
+ override fun beforeDocumentSaving(document: Document) {
+ AmazonQLspService.executeIfRunning(project) { languageServer ->
+ val file = FileDocumentManager.getInstance().getFile(document) ?: return@executeIfRunning
+ file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.let { uri ->
+ languageServer.textDocumentService.didSave(
+ DidSaveTextDocumentParams().apply {
+ textDocument = TextDocumentIdentifier().apply {
+ this.uri = uri
+ }
+ text = document.text
+ }
+ )
+ }
+ }
+ }
+
+ override fun after(events: MutableList) {
+ AmazonQLspService.executeIfRunning(project) { languageServer ->
+ pluginAwareExecuteOnPooledThread {
+ events.filterIsInstance().forEach { event ->
+ val document = FileDocumentManager.getInstance().getCachedDocument(event.file) ?: return@forEach
+ event.file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.let { uri ->
+ languageServer.textDocumentService.didChange(
+ DidChangeTextDocumentParams().apply {
+ textDocument = VersionedTextDocumentIdentifier().apply {
+ this.uri = uri
+ version = document.modificationStamp.toInt()
+ }
+ contentChanges = listOf(
+ TextDocumentContentChangeEvent().apply {
+ text = document.text
+ }
+ )
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+
+ override fun fileOpened(
+ source: FileEditorManager,
+ file: VirtualFile,
+ ) {
+ AmazonQLspService.executeIfRunning(project) { languageServer ->
+ file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.let { uri ->
+ languageServer.textDocumentService.didOpen(
+ DidOpenTextDocumentParams().apply {
+ textDocument = TextDocumentItem().apply {
+ this.uri = uri
+ text = file.inputStream.readAllBytes().decodeToString()
+ }
+ }
+ )
+ }
+ }
+ }
+
+ override fun fileClosed(
+ source: FileEditorManager,
+ file: VirtualFile,
+ ) {
+ AmazonQLspService.executeIfRunning(project) { languageServer ->
+ file.toNioPath().toUri().toString().takeIf { it.isNotEmpty() }?.let { uri ->
+ languageServer.textDocumentService.didClose(
+ DidCloseTextDocumentParams().apply {
+ textDocument = TextDocumentIdentifier().apply {
+ this.uri = uri
+ }
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt
new file mode 100644
index 00000000000..86421e0acae
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt
@@ -0,0 +1,301 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument
+
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.application.Application
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.components.serviceIfCreated
+import com.intellij.openapi.editor.Document
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.vfs.VirtualFile
+import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent
+import com.intellij.openapi.vfs.newvfs.events.VFileEvent
+import com.intellij.util.messages.MessageBus
+import com.intellij.util.messages.MessageBusConnection
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkStatic
+import io.mockk.runs
+import io.mockk.slot
+import io.mockk.verify
+import kotlinx.coroutines.test.runTest
+import org.eclipse.lsp4j.DidChangeTextDocumentParams
+import org.eclipse.lsp4j.DidCloseTextDocumentParams
+import org.eclipse.lsp4j.DidOpenTextDocumentParams
+import org.eclipse.lsp4j.DidSaveTextDocumentParams
+import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage
+import org.eclipse.lsp4j.services.TextDocumentService
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLanguageServer
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService
+import java.net.URI
+import java.nio.file.Path
+import java.util.concurrent.Callable
+import java.util.concurrent.CompletableFuture
+
+class TextDocumentServiceHandlerTest {
+ private lateinit var project: Project
+ private lateinit var mockLanguageServer: AmazonQLanguageServer
+ private lateinit var mockTextDocumentService: TextDocumentService
+ private lateinit var sut: TextDocumentServiceHandler
+ private lateinit var mockApplication: Application
+
+ @Before
+ fun setup() {
+ project = mockk()
+ mockTextDocumentService = mockk()
+ mockLanguageServer = mockk()
+
+ mockApplication = mockk()
+ mockkStatic(ApplicationManager::class)
+ every { ApplicationManager.getApplication() } returns mockApplication
+ every { mockApplication.executeOnPooledThread(any>()) } answers {
+ CompletableFuture.completedFuture(firstArg>().call())
+ }
+
+ // Mock the LSP service
+ val mockLspService = mockk()
+
+ // Mock the service methods on Project
+ every { project.getService(AmazonQLspService::class.java) } returns mockLspService
+ every { project.serviceIfCreated() } returns mockLspService
+
+ // Mock the LSP service's executeSync method as a suspend function
+ every {
+ mockLspService.executeSync>(any())
+ } coAnswers {
+ val func = firstArg CompletableFuture>()
+ func.invoke(mockLanguageServer)
+ }
+
+ // Mock workspace service
+ every { mockLanguageServer.textDocumentService } returns mockTextDocumentService
+ every { mockTextDocumentService.didChange(any()) } returns Unit
+ every { mockTextDocumentService.didSave(any()) } returns Unit
+ every { mockTextDocumentService.didOpen(any()) } returns Unit
+ every { mockTextDocumentService.didClose(any()) } returns Unit
+
+ // Mock message bus
+ val messageBus = mockk()
+ every { project.messageBus } returns messageBus
+ val mockConnection = mockk()
+ every { messageBus.connect(any()) } returns mockConnection
+ every { mockConnection.subscribe(any(), any()) } just runs
+
+ sut = TextDocumentServiceHandler(project, mockk())
+ }
+
+ @Test
+ fun `didSave runs on beforeDocumentSaving`() = runTest {
+ // Create test document and file
+ val uri = URI.create("file:///test/path/file.txt")
+ val document = mockk {
+ every { text } returns "test content"
+ }
+
+ val path = mockk {
+ every { toUri() } returns uri
+ }
+
+ val file = mockk {
+ every { this@mockk.path } returns uri.path
+ every { toNioPath() } returns path
+ }
+
+ // Mock FileDocumentManager
+ val fileDocumentManager = mockk {
+ every { getFile(document) } returns file
+ }
+
+ // Replace the FileDocumentManager instance
+ mockkStatic(FileDocumentManager::class) {
+ every { FileDocumentManager.getInstance() } returns fileDocumentManager
+
+ // Call the handler method
+ sut.beforeDocumentSaving(document)
+
+ // Verify the correct LSP method was called with matching parameters
+ val paramsSlot = slot()
+ verify { mockTextDocumentService.didSave(capture(paramsSlot)) }
+
+ with(paramsSlot.captured) {
+ assertEquals(uri.toString(), textDocument.uri)
+ assertEquals("test content", text)
+ }
+ }
+ }
+
+ @Test
+ fun `didOpen runs on fileOpened`() = runTest {
+ // Create test file
+ val uri = URI.create("file:///test/path/file.txt")
+ val content = "test content"
+ val inputStream = content.byteInputStream()
+
+ val path = mockk {
+ every { toUri() } returns uri
+ }
+
+ val file = mockk {
+ every { this@mockk.path } returns uri.path
+ every { toNioPath() } returns path
+ every { this@mockk.inputStream } returns inputStream
+ }
+
+ // Call the handler method
+ sut.fileOpened(mockk(), file)
+
+ // Verify the correct LSP method was called with matching parameters
+ val paramsSlot = slot()
+ verify { mockTextDocumentService.didOpen(capture(paramsSlot)) }
+
+ with(paramsSlot.captured.textDocument) {
+ assertEquals(uri.toString(), this.uri)
+ assertEquals(content, text)
+ }
+ }
+
+ @Test
+ fun `didClose runs on fileClosed`() = runTest {
+ val uri = URI.create("file:///test/path/file.txt")
+ val path = mockk {
+ every { toUri() } returns uri
+ }
+ val file = mockk {
+ every { this@mockk.path } returns uri.path
+ every { toNioPath() } returns path
+ }
+
+ sut.fileClosed(mockk(), file)
+
+ val paramsSlot = slot()
+ verify { mockTextDocumentService.didClose(capture(paramsSlot)) }
+
+ assertEquals(uri.toString(), paramsSlot.captured.textDocument.uri)
+ }
+
+ @Test
+ fun `didChange runs on content change events`() = runTest {
+ val uri = URI.create("file:///test/path/file.txt")
+ val document = mockk {
+ every { text } returns "changed content"
+ every { modificationStamp } returns 123L
+ }
+
+ val path = mockk {
+ every { toUri() } returns uri
+ }
+
+ val file = mockk {
+ every { this@mockk.path } returns uri.path
+ every { toNioPath() } returns path
+ }
+
+ val changeEvent = mockk {
+ every { this@mockk.file } returns file
+ }
+
+ // Mock FileDocumentManager
+ val fileDocumentManager = mockk {
+ every { getCachedDocument(file) } returns document
+ }
+
+ mockkStatic(FileDocumentManager::class) {
+ every { FileDocumentManager.getInstance() } returns fileDocumentManager
+
+ // Call the handler method
+ sut.after(mutableListOf(changeEvent))
+ }
+
+ // Verify the correct LSP method was called with matching parameters
+ val paramsSlot = slot()
+ verify { mockTextDocumentService.didChange(capture(paramsSlot)) }
+
+ with(paramsSlot.captured) {
+ assertEquals(uri.toString(), textDocument.uri)
+ assertEquals(123, textDocument.version)
+ assertEquals("changed content", contentChanges[0].text)
+ }
+ }
+
+ @Test
+ fun `didSave does not run when URI is empty`() = runTest {
+ val document = mockk()
+ val path = mockk {
+ every { toUri() } returns URI.create("")
+ }
+ val file = mockk {
+ every { toNioPath() } returns path
+ }
+
+ val fileDocumentManager = mockk {
+ every { getFile(document) } returns file
+ }
+
+ mockkStatic(FileDocumentManager::class) {
+ every { FileDocumentManager.getInstance() } returns fileDocumentManager
+
+ sut.beforeDocumentSaving(document)
+
+ verify(exactly = 0) { mockTextDocumentService.didSave(any()) }
+ }
+ }
+
+ @Test
+ fun `didSave does not run when file is null`() = runTest {
+ val document = mockk()
+
+ val fileDocumentManager = mockk {
+ every { getFile(document) } returns null
+ }
+
+ mockkStatic(FileDocumentManager::class) {
+ every { FileDocumentManager.getInstance() } returns fileDocumentManager
+
+ sut.beforeDocumentSaving(document)
+
+ verify(exactly = 0) { mockTextDocumentService.didSave(any()) }
+ }
+ }
+
+ @Test
+ fun `didChange ignores non-content change events`() = runTest {
+ val nonContentEvent = mockk() // Some other type of VFileEvent
+
+ sut.after(mutableListOf(nonContentEvent))
+
+ verify(exactly = 0) { mockTextDocumentService.didChange(any()) }
+ }
+
+ @Test
+ fun `didChange skips files without cached documents`() = runTest {
+ val uri = URI.create("file:///test/path/file.txt")
+ val path = mockk {
+ every { toUri() } returns uri
+ }
+ val file = mockk {
+ every { toNioPath() } returns path
+ }
+ val changeEvent = mockk {
+ every { this@mockk.file } returns file
+ }
+
+ val fileDocumentManager = mockk {
+ every { getCachedDocument(file) } returns null
+ }
+
+ mockkStatic(FileDocumentManager::class) {
+ every { FileDocumentManager.getInstance() } returns fileDocumentManager
+
+ sut.after(mutableListOf(changeEvent))
+
+ verify(exactly = 0) { mockTextDocumentService.didChange(any()) }
+ }
+ }
+}
From 0b6e695a6891d0c5612d35cdd5c21c7ab8533024 Mon Sep 17 00:00:00 2001
From: Richard Li <742829+rli@users.noreply.github.com>
Date: Tue, 4 Mar 2025 16:57:10 -0800
Subject: [PATCH 020/117] fix(amazonq): fix failing
FileUri/TextDocumentServiceHandler tests (#5437)
---
.../TextDocumentServiceHandlerTest.kt | 4 +-
.../amazonq/lsp/util/FileUriUtilTest.kt | 45 ++++++++++---------
2 files changed, 26 insertions(+), 23 deletions(-)
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt
index 86421e0acae..cd8a8611639 100644
--- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt
@@ -70,8 +70,8 @@ class TextDocumentServiceHandlerTest {
every {
mockLspService.executeSync>(any())
} coAnswers {
- val func = firstArg CompletableFuture>()
- func.invoke(mockLanguageServer)
+ val func = firstArg CompletableFuture>()
+ func.invoke(mockLspService, mockLanguageServer)
}
// Mock workspace service
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt
index 26c420779d0..4418cb33ac8 100644
--- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/FileUriUtilTest.kt
@@ -3,20 +3,23 @@
package software.aws.toolkits.jetbrains.services.amazonq.lsp.util
import com.intellij.openapi.vfs.VirtualFile
-import com.intellij.testFramework.fixtures.BasePlatformTestCase
+import com.intellij.testFramework.ApplicationExtension
import io.mockk.every
import io.mockk.mockk
-import org.junit.Test
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
-class FileUriUtilTest : BasePlatformTestCase() {
+@ExtendWith(ApplicationExtension::class)
+class FileUriUtilTest {
- private fun createMockVirtualFile(path: String, protocol: String = "file", isDirectory: Boolean = false): VirtualFile =
+ private fun createMockVirtualFile(path: String, mockProtocol: String = "file", mockIsDirectory: Boolean = false): VirtualFile =
mockk {
every { fileSystem } returns mockk {
- every { this@mockk.protocol } returns protocol
+ every { protocol } returns mockProtocol
}
every { url } returns path
- every { this@mockk.isDirectory } returns isDirectory
+ every { isDirectory } returns mockIsDirectory
}
private fun normalizeFileUri(uri: String): String {
@@ -37,15 +40,15 @@ class FileUriUtilTest : BasePlatformTestCase() {
val virtualFile = createMockVirtualFile("/path/to/file.txt")
val uri = FileUriUtil.toUriString(virtualFile)
val expected = normalizeFileUri("file:///path/to/file.txt")
- assertEquals(expected, uri)
+ assertThat(uri).isEqualTo(expected)
}
@Test
fun `test unix directory path`() {
- val virtualFile = createMockVirtualFile("/path/to/directory/", isDirectory = true)
+ val virtualFile = createMockVirtualFile("/path/to/directory/", mockIsDirectory = true)
val uri = FileUriUtil.toUriString(virtualFile)
val expected = normalizeFileUri("file:///path/to/directory")
- assertEquals(expected, uri)
+ assertThat(uri).isEqualTo(expected)
}
@Test
@@ -53,7 +56,7 @@ class FileUriUtilTest : BasePlatformTestCase() {
val virtualFile = createMockVirtualFile("/path/with spaces/file.txt")
val uri = FileUriUtil.toUriString(virtualFile)
val expected = normalizeFileUri("file:///path/with%20spaces/file.txt")
- assertEquals(expected, uri)
+ assertThat(uri).isEqualTo(expected)
}
@Test
@@ -61,7 +64,7 @@ class FileUriUtilTest : BasePlatformTestCase() {
val virtualFile = createMockVirtualFile("/")
val uri = FileUriUtil.toUriString(virtualFile)
val expected = normalizeFileUri("file:///")
- assertEquals(expected, uri)
+ assertThat(uri).isEqualTo(expected)
}
@Test
@@ -69,7 +72,7 @@ class FileUriUtilTest : BasePlatformTestCase() {
val virtualFile = createMockVirtualFile("/path//to///file.txt")
val uri = FileUriUtil.toUriString(virtualFile)
val expected = normalizeFileUri("file:///path/to/file.txt")
- assertEquals(expected, uri)
+ assertThat(uri).isEqualTo(expected)
}
@Test
@@ -78,8 +81,8 @@ class FileUriUtilTest : BasePlatformTestCase() {
val virtualFile = createMockVirtualFile(longPath)
val uri = FileUriUtil.toUriString(virtualFile)
if (uri != null) {
- assertTrue(uri.startsWith("file:///"))
- assertTrue(uri.endsWith("/file.txt"))
+ assertThat(uri.startsWith("file:///")).isTrue
+ assertThat(uri.endsWith("/file.txt")).isTrue
}
}
@@ -88,8 +91,8 @@ class FileUriUtilTest : BasePlatformTestCase() {
val virtualFile = createMockVirtualFile("./relative/path/file.txt")
val uri = FileUriUtil.toUriString(virtualFile)
if (uri != null) {
- assertTrue(uri.contains("file.txt"))
- assertTrue(uri.startsWith("file:///"))
+ assertThat(uri.contains("file.txt")).isTrue
+ assertThat(uri.startsWith("file:///")).isTrue
}
}
@@ -101,7 +104,7 @@ class FileUriUtilTest : BasePlatformTestCase() {
)
val result = FileUriUtil.toUriString(virtualFile)
val expected = normalizeFileUri("jar:file:///path/to/archive.jar!/com/example/Test.class")
- assertEquals(expected, result)
+ assertThat(result).isEqualTo(expected)
}
@Test
@@ -112,7 +115,7 @@ class FileUriUtilTest : BasePlatformTestCase() {
)
val result = FileUriUtil.toUriString(virtualFile)
val expected = normalizeFileUri("jrt://java.base/java/lang/String.class")
- assertEquals(expected, result)
+ assertThat(result).isEqualTo(expected)
}
@Test
@@ -122,7 +125,7 @@ class FileUriUtilTest : BasePlatformTestCase() {
"jar"
)
val result = FileUriUtil.toUriString(virtualFile)
- assertNull(result)
+ assertThat(result).isNull()
}
@Test
@@ -134,7 +137,7 @@ class FileUriUtilTest : BasePlatformTestCase() {
)
val result = FileUriUtil.toUriString(virtualFile)
val expected = normalizeFileUri("jar:file:///path/to/archive.jar!/com/example")
- assertEquals(expected, result)
+ assertThat(result).isEqualTo(expected)
}
@Test
@@ -145,6 +148,6 @@ class FileUriUtilTest : BasePlatformTestCase() {
true
)
val result = FileUriUtil.toUriString(virtualFile)
- assertNull(result)
+ assertThat(result).isNull()
}
}
From 84b0f7d2672073664b499f4045e0337ea7bbb087 Mon Sep 17 00:00:00 2001
From: Lokesh
Date: Thu, 6 Mar 2025 08:58:39 -0800
Subject: [PATCH 021/117] feat(amazonq): Added changes for override lsp
artifacts (#5429)
---
.../settings/CodeWhispererConfigurable.kt | 22 +++++
.../jetbrains/settings/LspSettings.kt | 44 ++++++++++
.../jetbrains/settings/LspSettingsTest.kt | 85 +++++++++++++++++++
.../resources/MessagesBundle.properties | 2 +
4 files changed, 153 insertions(+)
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/LspSettings.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt
diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt
index 94bcc93f5ca..91cff463cd0 100644
--- a/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt
+++ b/plugins/amazonq/codewhisperer/jetbrains-community/src/software/aws/toolkits/jetbrains/services/codewhisperer/settings/CodeWhispererConfigurable.kt
@@ -5,13 +5,16 @@ package software.aws.toolkits.jetbrains.services.codewhisperer.settings
import com.intellij.icons.AllIcons
import com.intellij.ide.DataManager
+import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.openapi.options.BoundConfigurable
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.SearchableConfigurable
import com.intellij.openapi.options.ex.Settings
import com.intellij.openapi.project.Project
+import com.intellij.openapi.ui.emptyText
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.fields.ExpandableTextField
+import com.intellij.ui.dsl.builder.Align
import com.intellij.ui.dsl.builder.bindIntText
import com.intellij.ui.dsl.builder.bindSelected
import com.intellij.ui.dsl.builder.bindText
@@ -24,6 +27,7 @@ import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWh
import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExplorerActionManager
import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled
import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings
+import software.aws.toolkits.jetbrains.settings.LspSettings
import software.aws.toolkits.resources.message
import java.awt.Font
import java.util.concurrent.TimeUnit
@@ -61,6 +65,24 @@ class CodeWhispererConfigurable(private val project: Project) :
}
}
+ group(message("amazonqFeatureDev.placeholder.lsp")) {
+ row(message("amazonqFeatureDev.placeholder.select_lsp_artifact")) {
+ val fileChooserDescriptor = FileChooserDescriptorFactory.createSingleFileDescriptor()
+ fileChooserDescriptor.isForcedToUseIdeaFileChooser = true
+
+ textFieldWithBrowseButton(fileChooserDescriptor = fileChooserDescriptor)
+ .bindText(
+ { LspSettings.getInstance().getArtifactPath() },
+ { LspSettings.getInstance().setArtifactPath(it.takeIf { v -> v.isNotBlank() }) }
+ )
+ .applyToComponent {
+ emptyText.text = "Choose a file to upload"
+ }
+ .resizableColumn()
+ .align(Align.FILL)
+ }
+ }
+
group(message("aws.settings.codewhisperer.group.inline_suggestions")) {
row {
checkBox(message("aws.settings.codewhisperer.include_code_with_reference")).apply {
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/LspSettings.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/LspSettings.kt
new file mode 100644
index 00000000000..8ac9d527651
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/LspSettings.kt
@@ -0,0 +1,44 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.settings
+
+import com.intellij.openapi.components.BaseState
+import com.intellij.openapi.components.PersistentStateComponent
+import com.intellij.openapi.components.RoamingType
+import com.intellij.openapi.components.Service
+import com.intellij.openapi.components.State
+import com.intellij.openapi.components.Storage
+import com.intellij.openapi.components.service
+import com.intellij.util.xmlb.annotations.Property
+
+@Service
+@State(name = "lspSettings", storages = [Storage("aws.xml", roamingType = RoamingType.DISABLED)])
+class LspSettings : PersistentStateComponent {
+ private var state = LspConfiguration()
+
+ override fun getState(): LspConfiguration = state
+
+ override fun loadState(state: LspConfiguration) {
+ this.state = state
+ }
+
+ fun getArtifactPath() = state.artifactPath
+
+ fun setArtifactPath(artifactPath: String?) {
+ if (artifactPath == null) {
+ state.artifactPath = ""
+ } else {
+ state.artifactPath = artifactPath
+ }
+ }
+
+ companion object {
+ fun getInstance(): LspSettings = service()
+ }
+}
+
+class LspConfiguration : BaseState() {
+ @get:Property
+ var artifactPath: String = ""
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt
new file mode 100644
index 00000000000..3d1e240beb7
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/settings/LspSettingsTest.kt
@@ -0,0 +1,85 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.settings
+
+import com.intellij.util.xmlb.XmlSerializer
+import org.assertj.core.api.Assertions.assertThat
+import org.jdom.output.XMLOutputter
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import software.aws.toolkits.jetbrains.utils.xmlElement
+
+class LspSettingsTest {
+ private lateinit var lspSettings: LspSettings
+
+ @BeforeEach
+ fun setUp() {
+ lspSettings = LspSettings()
+ lspSettings.loadState(LspConfiguration())
+ }
+
+ @Test
+ fun `artifact path is empty by default`() {
+ assertThat(lspSettings.getArtifactPath()).isEmpty()
+ }
+
+ @Test
+ fun `artifact path can be set`() {
+ lspSettings.setArtifactPath("test\\lsp.js")
+ assertThat(lspSettings.getArtifactPath()).isNotEmpty()
+ assertThat(lspSettings.getArtifactPath()).isEqualTo("test\\lsp.js")
+ }
+
+ @Test
+ fun `artifact path cannot be null`() {
+ lspSettings.setArtifactPath(null)
+ assertThat(lspSettings.getArtifactPath()).isEmpty()
+ }
+
+ @Test
+ fun `serialize settings to ensure backwards compatibility`() {
+ val element = xmlElement(
+ """
+
+
+ """.trimIndent()
+ )
+ lspSettings.setArtifactPath("temp\\lsp.js")
+
+ XmlSerializer.serializeInto(lspSettings.state, element)
+
+ val actual = XMLOutputter().outputString(element)
+
+ val expected = "\n" +
+ ""
+
+ assertThat(actual).isEqualTo(expected)
+ }
+
+ @Test
+ fun `deserialize empty settings to ensure backwards compatibility`() {
+ val element = xmlElement(
+ """
+
+
+ """
+ )
+ val actual = XmlSerializer.deserialize(element, LspConfiguration::class.java)
+ assertThat(actual.artifactPath).isEmpty()
+ }
+
+ @Test
+ fun `deserialize existing settings to ensure backwards compatibility`() {
+ val element = xmlElement(
+ """
+
+
+
+ """.trimIndent()
+ )
+ val actual = XmlSerializer.deserialize(element, LspConfiguration::class.java)
+ assertThat(actual.artifactPath).isNotEmpty()
+ assertThat(actual.artifactPath).isEqualTo("temp\\lsp.js")
+ }
+}
diff --git a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties
index 2d756cb51b5..6fd97883b49 100644
--- a/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties
+++ b/plugins/core/resources/resources/software/aws/toolkits/resources/MessagesBundle.properties
@@ -143,8 +143,10 @@ amazonqFeatureDev.placeholder.closed_session=Open a new chat tab to continue
amazonqFeatureDev.placeholder.context_gathering_complete=Gathering context...
amazonqFeatureDev.placeholder.downloading_and_extracting_lsp_artifacts=Downloading and Extracting Lsp Artifacts...
amazonqFeatureDev.placeholder.generating_code=Generating code...
+amazonqFeatureDev.placeholder.lsp=LSP
amazonqFeatureDev.placeholder.new_plan=Describe your task or issue in as much detail as possible
amazonqFeatureDev.placeholder.provide_code_feedback=Provide feedback or comments
+amazonqFeatureDev.placeholder.select_lsp_artifact=Select LSP Artifact
amazonqFeatureDev.placeholder.write_new_prompt=Write a new prompt
apprunner.action.configure=Configure Service
apprunner.action.create.service=Create Service...
From b179b3745e4ab4931825c59429d4f24dc50de603 Mon Sep 17 00:00:00 2001
From: Richard Li <742829+rli@users.noreply.github.com>
Date: Thu, 6 Mar 2025 17:57:11 -0800
Subject: [PATCH 022/117] feat(amazonq): implement
configuration/didChangeConfiguration message for data sharing (#5441)
---
.../CodeWhispererSettingsTest.kt | 20 ++++++
.../amazonq/lsp/AmazonQLanguageClientImpl.kt | 12 ++++
.../amazonq/lsp/AmazonQLspConstants.kt | 9 +++
.../services/amazonq/lsp/AmazonQLspService.kt | 7 ++
.../lsp/CodeWhispererLspConfiguration.kt | 11 +++
.../settings/CodeWhispererSettings.kt | 9 +++
.../lsp/AmazonQLanguageClientImplTest.kt | 70 +++++++++++++++++++
7 files changed, 138 insertions(+)
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspConstants.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/CodeWhispererLspConfiguration.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImplTest.kt
diff --git a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt
index 63bc70ae2b8..48b36bd9cea 100644
--- a/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt
+++ b/plugins/amazonq/codewhisperer/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/codewhisperer/CodeWhispererSettingsTest.kt
@@ -13,10 +13,14 @@ import com.intellij.openapi.wm.impl.status.widget.StatusBarWidgetsManager
import com.intellij.testFramework.replaceService
import com.intellij.testFramework.runInEdtAndWait
import com.intellij.util.xmlb.XmlSerializer
+import io.mockk.every
+import io.mockk.junit4.MockKRule
+import io.mockk.mockkObject
import org.assertj.core.api.Assertions.assertThat
import org.jdom.output.XMLOutputter
import org.junit.Before
import org.junit.Ignore
+import org.junit.Rule
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.never
@@ -24,6 +28,7 @@ import org.mockito.kotlin.spy
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import software.aws.toolkits.jetbrains.core.ToolWindowHeadlessManagerImpl
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService
import software.aws.toolkits.jetbrains.services.codewhisperer.credentials.CodeWhispererLoginType
import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.CodeWhispererExploreActionState
import software.aws.toolkits.jetbrains.services.codewhisperer.explorer.isCodeWhispererEnabled
@@ -40,6 +45,9 @@ class CodeWhispererSettingsTest : CodeWhispererTestBase() {
private lateinit var codewhispererServiceSpy: CodeWhispererService
private lateinit var toolWindowHeadlessManager: ToolWindowHeadlessManagerImpl
+ @get:Rule
+ val mockkRule = MockKRule(this)
+
@Before
override fun setUp() {
super.setUp()
@@ -212,6 +220,18 @@ class CodeWhispererSettingsTest : CodeWhispererTestBase() {
assertThat(actual.autoBuildSetting).hasSize(1)
assertThat(actual.autoBuildSetting["project1"]).isTrue()
}
+
+ @Test
+ fun `toggleMetricOptIn should trigger LSP didChangeConfiguration`() {
+ mockkObject(AmazonQLspService)
+ every { AmazonQLspService.didChangeConfiguration(any()) } returns Unit
+ settingsManager.toggleMetricOptIn(true)
+ settingsManager.toggleMetricOptIn(false)
+
+ io.mockk.verify(atLeast = 2) {
+ AmazonQLspService.didChangeConfiguration(any())
+ }
+ }
}
class CodeWhispererSettingUnitTest {
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt
index ce8aa6368a4..cb6388afed5 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt
@@ -12,6 +12,7 @@ import org.eclipse.lsp4j.PublishDiagnosticsParams
import org.eclipse.lsp4j.ShowMessageRequestParams
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.SsoProfileData
+import software.aws.toolkits.jetbrains.settings.CodeWhispererSettings
import java.util.concurrent.CompletableFuture
/**
@@ -58,6 +59,17 @@ class AmazonQLanguageClientImpl : AmazonQLanguageClient {
return CompletableFuture.completedFuture(
buildList {
+ params.items.forEach {
+ when (it.section) {
+ AmazonQLspConstants.LSP_CW_CONFIGURATION_KEY -> {
+ add(
+ CodeWhispererLspConfiguration(
+ shouldShareData = CodeWhispererSettings.getInstance().isMetricOptIn()
+ )
+ )
+ }
+ }
+ }
}
)
}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspConstants.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspConstants.kt
new file mode 100644
index 00000000000..0894a875f43
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspConstants.kt
@@ -0,0 +1,9 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp
+
+object AmazonQLspConstants {
+ const val LSP_CW_CONFIGURATION_KEY = "aws.codeWhisperer"
+ const val LSP_CW_OPT_OUT_KEY = "shareCodeWhispererContentWithAWS"
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
index 501d93e78e8..3e15275bfcb 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
@@ -29,6 +29,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import org.eclipse.lsp4j.ClientCapabilities
import org.eclipse.lsp4j.ClientInfo
+import org.eclipse.lsp4j.DidChangeConfigurationParams
import org.eclipse.lsp4j.FileOperationsWorkspaceCapabilities
import org.eclipse.lsp4j.InitializeParams
import org.eclipse.lsp4j.InitializeResult
@@ -178,6 +179,12 @@ class AmazonQLspService(private val project: Project, private val cs: CoroutineS
fun executeIfRunning(project: Project, runnable: AmazonQLspService.(AmazonQLanguageServer) -> T): T? =
project.serviceIfCreated()?.executeSync(runnable)
+
+ fun didChangeConfiguration(project: Project) {
+ executeIfRunning(project) {
+ it.workspaceService.didChangeConfiguration(DidChangeConfigurationParams())
+ }
+ }
}
}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/CodeWhispererLspConfiguration.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/CodeWhispererLspConfiguration.kt
new file mode 100644
index 00000000000..6442e4cc380
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/CodeWhispererLspConfiguration.kt
@@ -0,0 +1,11 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp
+
+import com.google.gson.annotations.SerializedName
+
+data class CodeWhispererLspConfiguration(
+ @SerializedName(AmazonQLspConstants.LSP_CW_OPT_OUT_KEY)
+ val shouldShareData: Boolean? = null,
+)
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/CodeWhispererSettings.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/CodeWhispererSettings.kt
index 45b96113dcc..9e78c5aa17f 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/CodeWhispererSettings.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/settings/CodeWhispererSettings.kt
@@ -10,7 +10,9 @@ import com.intellij.openapi.components.Service
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
import com.intellij.openapi.components.service
+import com.intellij.openapi.project.ProjectManager
import com.intellij.util.xmlb.annotations.Property
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService
import software.aws.toolkits.jetbrains.utils.notifyInfo
import software.aws.toolkits.resources.AmazonQBundle
@@ -49,6 +51,13 @@ class CodeWhispererSettings : PersistentStateComponent
Date: Fri, 7 Mar 2025 13:04:00 -0800
Subject: [PATCH 023/117] feat(amazonq): Implement aws/syncModuleDependencies
call (#5414)
* syncModuleDependencies call barebones
* implement syncModuleDependencies logic
* detekt
* return completableFuture
* ExtensionPoint impl
* detekt
* fix EachExtensionSafe
* update ExtensionPoint namespace
* private functions not needed
* detekt
* cs.launch for the ModuleDependencyServiceHandler
* Tests
* update xml
* move xml impl to src root
* feedback
* change message to notification instead of Request
---
.../resources/META-INF/module-amazonq.xml | 1 +
.../amazonq/lsp/AmazonQLanguageServer.kt | 4 +
.../services/amazonq/lsp/AmazonQLspService.kt | 5 +
.../DefaultModuleDependenciesService.kt | 52 ++++
.../dependencies/ModuleDependenciesService.kt | 11 +
.../dependencies/ModuleDependencyProvider.kt | 17 ++
.../providers/JavaModuleDependencyProvider.kt | 46 ++++
.../PythonModuleDependencyProvider.kt | 45 ++++
.../SyncModuleDependenciesParams.kt | 13 +
.../DefaultModuleDependenciesServiceTest.kt | 234 ++++++++++++++++++
.../resources/META-INF/amazonq-ext-java.xml | 6 +
.../resources/META-INF/amazonq-ext-python.xml | 6 +
.../src/main/resources/META-INF/plugin.xml | 6 +
13 files changed, 446 insertions(+)
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependenciesService.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependencyProvider.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/JavaModuleDependencyProvider.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/PythonModuleDependencyProvider.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/dependencies/SyncModuleDependenciesParams.kt
create mode 100644 plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt
diff --git a/plugins/amazonq/shared/jetbrains-community/resources/META-INF/module-amazonq.xml b/plugins/amazonq/shared/jetbrains-community/resources/META-INF/module-amazonq.xml
index 6336e3526d6..95bb5c886e2 100644
--- a/plugins/amazonq/shared/jetbrains-community/resources/META-INF/module-amazonq.xml
+++ b/plugins/amazonq/shared/jetbrains-community/resources/META-INF/module-amazonq.xml
@@ -9,4 +9,5 @@
+
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt
index e8281f6b56a..0921d0e8eeb 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageServer.kt
@@ -8,6 +8,7 @@ import org.eclipse.lsp4j.jsonrpc.services.JsonNotification
import org.eclipse.lsp4j.jsonrpc.services.JsonRequest
import org.eclipse.lsp4j.services.LanguageServer
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.UpdateCredentialsPayload
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.SyncModuleDependenciesParams
import java.util.concurrent.CompletableFuture
/**
@@ -15,6 +16,9 @@ import java.util.concurrent.CompletableFuture
*/
@Suppress("unused")
interface AmazonQLanguageServer : LanguageServer {
+ @JsonNotification("aws/syncModuleDependencies")
+ fun syncModuleDependencies(params: SyncModuleDependenciesParams): CompletableFuture
+
@JsonRequest("aws/credentials/token/update")
fun updateTokenCredentials(payload: UpdateCredentialsPayload): CompletableFuture
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
index 3e15275bfcb..517ccec8539 100644
--- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt
@@ -23,6 +23,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -45,6 +46,7 @@ import software.aws.toolkits.core.utils.info
import software.aws.toolkits.core.utils.warn
import software.aws.toolkits.jetbrains.isDeveloperMode
import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.DefaultModuleDependenciesService
import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.createExtendedClientMetadata
import software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument.TextDocumentServiceHandler
@@ -310,6 +312,9 @@ private class AmazonQServerInstance(private val project: Project, private val cs
DefaultAuthCredentialsService(project, encryptionManager, this)
TextDocumentServiceHandler(project, this)
WorkspaceServiceHandler(project, this)
+ cs.launch {
+ DefaultModuleDependenciesService(project, this@AmazonQServerInstance)
+ }
}
override fun dispose() {
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt
new file mode 100644
index 00000000000..513bf8adf0b
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt
@@ -0,0 +1,52 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies
+
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.module.ModuleManager
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.roots.ModuleRootEvent
+import com.intellij.openapi.roots.ModuleRootListener
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.ModuleDependencyProvider.Companion.EP_NAME
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.SyncModuleDependenciesParams
+import java.util.concurrent.CompletableFuture
+
+class DefaultModuleDependenciesService(
+ private val project: Project,
+ serverInstance: Disposable,
+) : ModuleDependenciesService,
+ ModuleRootListener {
+
+ init {
+ project.messageBus.connect(serverInstance).subscribe(
+ ModuleRootListener.TOPIC,
+ this
+ )
+ // project initiation with initial list of dependencies
+ syncAllModules()
+ }
+
+ override fun rootsChanged(event: ModuleRootEvent) {
+ if (event.isCausedByFileTypesChange) return
+ // call on change with updated dependencies
+ syncAllModules()
+ }
+
+ override fun syncModuleDependencies(params: SyncModuleDependenciesParams): CompletableFuture =
+ AmazonQLspService.executeIfRunning(project) { languageServer ->
+ languageServer.syncModuleDependencies(params)
+ }?.toCompletableFuture() ?: CompletableFuture.failedFuture(IllegalStateException("LSP Server not running"))
+
+ private fun syncAllModules() {
+ ModuleManager.getInstance(project).modules.forEach { module ->
+ EP_NAME.forEachExtensionSafe {
+ if (it.isApplicable(module)) {
+ syncModuleDependencies(it.createParams(module))
+ return@forEachExtensionSafe
+ }
+ }
+ }
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependenciesService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependenciesService.kt
new file mode 100644
index 00000000000..4229fe7042a
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependenciesService.kt
@@ -0,0 +1,11 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies
+
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.SyncModuleDependenciesParams
+import java.util.concurrent.CompletableFuture
+
+interface ModuleDependenciesService {
+ fun syncModuleDependencies(params: SyncModuleDependenciesParams): CompletableFuture
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependencyProvider.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependencyProvider.kt
new file mode 100644
index 00000000000..a6ce65b387a
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/ModuleDependencyProvider.kt
@@ -0,0 +1,17 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies
+
+import com.intellij.openapi.extensions.ExtensionPointName
+import com.intellij.openapi.module.Module
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.SyncModuleDependenciesParams
+
+interface ModuleDependencyProvider {
+ companion object {
+ val EP_NAME = ExtensionPointName("software.aws.toolkits.jetbrains.moduleDependencyProvider")
+ }
+
+ fun isApplicable(module: Module): Boolean
+ fun createParams(module: Module): SyncModuleDependenciesParams
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/JavaModuleDependencyProvider.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/JavaModuleDependencyProvider.kt
new file mode 100644
index 00000000000..26fa9fda84f
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/JavaModuleDependencyProvider.kt
@@ -0,0 +1,46 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.providers
+
+import com.intellij.openapi.module.Module
+import com.intellij.openapi.projectRoots.JavaSdkType
+import com.intellij.openapi.roots.ModuleRootManager
+import com.intellij.openapi.roots.OrderRootType
+import com.intellij.openapi.vfs.VfsUtil
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.ModuleDependencyProvider
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.SyncModuleDependenciesParams
+
+internal class JavaModuleDependencyProvider : ModuleDependencyProvider {
+ override fun isApplicable(module: Module): Boolean =
+ ModuleRootManager.getInstance(module).sdk?.sdkType is JavaSdkType
+
+ override fun createParams(module: Module): SyncModuleDependenciesParams {
+ val sourceRoots = getSourceRoots(module)
+ val dependencies = mutableListOf()
+
+ ModuleRootManager.getInstance(module).orderEntries().forEachLibrary { library ->
+ library.getUrls(OrderRootType.CLASSES).forEach { url ->
+ dependencies.add(VfsUtil.urlToPath(url))
+ }
+ true
+ }
+
+ return SyncModuleDependenciesParams(
+ moduleName = module.name,
+ programmingLanguage = "Java",
+ files = sourceRoots,
+ dirs = dependencies,
+ includePatterns = emptyList(),
+ excludePatterns = emptyList()
+ )
+ }
+
+ private fun getSourceRoots(module: Module): List =
+ ModuleRootManager.getInstance(module).contentEntries
+ .flatMap { contentEntry ->
+ contentEntry.sourceFolders
+ .filter { !it.isTestSource }
+ .mapNotNull { it.file?.path }
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/PythonModuleDependencyProvider.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/PythonModuleDependencyProvider.kt
new file mode 100644
index 00000000000..4223c7f9e80
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/providers/PythonModuleDependencyProvider.kt
@@ -0,0 +1,45 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.providers
+
+import com.intellij.openapi.module.Module
+import com.intellij.openapi.roots.ModuleRootManager
+import com.jetbrains.python.packaging.management.PythonPackageManager
+import com.jetbrains.python.sdk.PythonSdkUtil
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.ModuleDependencyProvider
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.SyncModuleDependenciesParams
+
+internal class PythonModuleDependencyProvider : ModuleDependencyProvider {
+ override fun isApplicable(module: Module): Boolean =
+ PythonSdkUtil.findPythonSdk(module) != null
+
+ override fun createParams(module: Module): SyncModuleDependenciesParams {
+ val sourceRoots = getSourceRoots(module)
+ val dependencies = mutableListOf()
+
+ PythonSdkUtil.findPythonSdk(module)?.let { sdk ->
+ val packageManager = PythonPackageManager.forSdk(module.project, sdk)
+ packageManager.installedPackages.forEach { pkg ->
+ dependencies.add(pkg.name)
+ }
+ }
+
+ return SyncModuleDependenciesParams(
+ moduleName = module.name,
+ programmingLanguage = "Python",
+ files = sourceRoots,
+ dirs = dependencies,
+ includePatterns = emptyList(),
+ excludePatterns = emptyList()
+ )
+ }
+
+ private fun getSourceRoots(module: Module): List =
+ ModuleRootManager.getInstance(module).contentEntries
+ .flatMap { contentEntry ->
+ contentEntry.sourceFolders
+ .filter { !it.isTestSource }
+ .mapNotNull { it.file?.path }
+ }
+}
diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/dependencies/SyncModuleDependenciesParams.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/dependencies/SyncModuleDependenciesParams.kt
new file mode 100644
index 00000000000..f7585ac1081
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/dependencies/SyncModuleDependenciesParams.kt
@@ -0,0 +1,13 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies
+
+class SyncModuleDependenciesParams(
+ val moduleName: String,
+ val programmingLanguage: String,
+ val files: List,
+ val dirs: List,
+ val includePatterns: List,
+ val excludePatterns: List,
+)
diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt
new file mode 100644
index 00000000000..89b51f9f6ec
--- /dev/null
+++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt
@@ -0,0 +1,234 @@
+// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+package software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies
+
+import com.intellij.openapi.Disposable
+import com.intellij.openapi.application.Application
+import com.intellij.openapi.application.ApplicationManager
+import com.intellij.openapi.components.serviceIfCreated
+import com.intellij.openapi.extensions.ExtensionPointName
+import com.intellij.openapi.module.Module
+import com.intellij.openapi.module.ModuleManager
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.roots.ModuleRootEvent
+import com.intellij.util.messages.MessageBus
+import com.intellij.util.messages.MessageBusConnection
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.mockkStatic
+import io.mockk.runs
+import io.mockk.verify
+import org.eclipse.lsp4j.jsonrpc.messages.ResponseMessage
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLanguageServer
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.ModuleDependencyProvider.Companion.EP_NAME
+import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.dependencies.SyncModuleDependenciesParams
+import java.util.concurrent.CompletableFuture
+import java.util.function.Consumer
+
+class DefaultModuleDependenciesServiceTest {
+ private lateinit var project: Project
+ private lateinit var mockLanguageServer: AmazonQLanguageServer
+ private lateinit var mockModuleManager: ModuleManager
+ private lateinit var sut: DefaultModuleDependenciesService
+ private lateinit var mockApplication: Application
+ private lateinit var mockDependencyProvider: ModuleDependencyProvider
+
+ @BeforeEach
+ fun setUp() {
+ project = mockk()
+ mockModuleManager = mockk()
+ mockDependencyProvider = mockk()
+ mockLanguageServer = mockk()
+
+ every { mockLanguageServer.syncModuleDependencies(any()) } returns CompletableFuture()
+
+ // Mock Application
+ mockApplication = mockk()
+ mockkStatic(ApplicationManager::class)
+ every { ApplicationManager.getApplication() } returns mockApplication
+
+ // Mock message bus
+ val messageBus = mockk()
+ every { project.messageBus } returns messageBus
+ val mockConnection = mockk()
+ every { messageBus.connect(any()) } returns mockConnection
+ every { mockConnection.subscribe(any(), any()) } just runs
+
+ // Mock ModuleManager
+ mockkStatic(ModuleManager::class)
+ every { ModuleManager.getInstance(project) } returns mockModuleManager
+ every { mockModuleManager.modules } returns Array(0) { mockk() }
+
+ // Mock LSP service
+ val mockLspService = mockk()
+ every { project.getService(AmazonQLspService::class.java) } returns mockLspService
+ every { project.serviceIfCreated() } returns mockLspService
+ every {
+ mockLspService.executeSync>(any())
+ } coAnswers {
+ val func = firstArg CompletableFuture>()
+ func.invoke(mockLspService, mockLanguageServer)
+ }
+
+ // Mock extension point
+ mockkObject(ModuleDependencyProvider.Companion)
+ val epName = mockk>()
+ every { EP_NAME } returns epName
+ every { epName.forEachExtensionSafe(any()) } answers {
+ val callback = firstArg<(ModuleDependencyProvider) -> Unit>()
+ callback(mockDependencyProvider)
+ }
+ }
+
+ @Test
+ fun `test initial sync on construction`() {
+ // Arrange
+ val module = mockk