diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index cc87a216..998f32ce 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -99,6 +99,7 @@ jobs: uses: softprops/action-gh-release@v1 with: tag_name: v${{ steps.set-version.outputs.version }} + target_commitish: ${{ github.sha }} prerelease: ${{ github.event_name != 'release' }} files: ${{ steps.set-version.outputs.name }}.vsix token: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e5d1f19..34243df1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ # Change Log ## [3.2.0] 06-Oct-2025 + Minimum VS Code version is now 1.104.0. + - Enhancements - Better error handling when expanding InterSystems and Projects Explorers (#1652) - Allow opening low-code editors from read-only file systems (#1655) @@ -17,6 +19,7 @@ Minimum VS Code version is now 1.104.0. - Upgrade dependencies (#1650, #1653) ## [3.0.6] 09-Sep-2025 + - Enhancements - Add `objectscript.unitTest.enabled` setting (#1627) - Add a `New File...` command for creating an Interoperability Message (#1629) @@ -33,6 +36,7 @@ Minimum VS Code version is now 1.104.0. - Don't append extra trailing newlines when saving server-side web app files (#1648) ## [3.0.5] 21-Jul-2025 + - Enhancements - Better telemetry (#1608) - Output the path to the export file when exporting to an XML file (#1616) @@ -47,6 +51,7 @@ Minimum VS Code version is now 1.104.0. - Fix resolving of server connection for files opened from InterSystems Explorer or XML preview (#1622) ## [3.0.4] 30-Jun-2025 + - Enhancements - Shorten server-side error messages (#1587) - Make it more clear when a server connection failed due to a timeout (#1592) @@ -56,6 +61,7 @@ Minimum VS Code version is now 1.104.0. - Fix comparing of local and server versions of web app files (#1599) ## [3.0.3] 16-Jun-2025 + - Enhancements - Cache the contents of files fetched during a debug session (#1579) - Fixes @@ -78,6 +84,7 @@ Minimum VS Code version is now 1.104.0. - Fix saving and re-opening of server-side documents on VS Code 1.101.0 (#1584) ## [3.0.2] 20-May-2025 + - Enhancements - Allow `objectscript.multilineMethodArgs` to be set per workspace folder (#1534) - Log out of web sessions when VS Code exits (#1540) @@ -97,11 +104,13 @@ Minimum VS Code version is now 1.104.0. - Don't report error when deleting a local file that doesn't exist on the server (#1555) ## [3.0.1] 04-Apr-2025 + - Fixes - Fix issue where `Undo` after a save deletes the file being edited (#1524) - Fix endless save loop when one local workspace folder is a subfolder of another (#1525) ## [3.0.0] 02-Apr-2025 + - Enhancements - Client-side editing overhaul (#1401, #1470, #1515, #1520): - Support the use of client-side editing in any non-isfs workspace folder, not just folders in your local file system. For example, with [VS Code Remote Development](https://code.visualstudio.com/docs/remote/remote-overview). @@ -142,10 +151,12 @@ Minimum VS Code version is now 1.104.0. - Upgrade dependencies ## [2.12.10] 13-Nov-2024 + - Fixes - Prevent overprompting for permission and account (#1456) ## [2.12.9] 29-Oct-2024 + - Enhancements - Add `Launch Lite Terminal` action to Explorer (#1438) - Add timeout to initial connection request (#1440) @@ -157,10 +168,12 @@ Minimum VS Code version is now 1.104.0. - Support for line wrapping in Lite Terminal (#1452) ## [2.12.8] 23-Sep-2024 + - Fixes - Solve 1.93 performance issue (#1428) ## [2.12.7] 05-Aug-2024 + - Enhancements - Fire source control hooks for opened and closed documents (#1414) - Always stop the debug target process when attaching (#1415) @@ -175,7 +188,9 @@ Minimum VS Code version is now 1.104.0. - Don't append CSPCHD for web applications that don't support it by default (#1420) ## [2.12.6] 23-Jul-2024 + Minimum VS Code version is now 1.91.0. + - Enhancements - Support command stepping in debugger (requires InterSystems IRIS 2023.1.5, 2024.1.1+, or 2024.2+) (#1385) - Add `Compile` command to server-side file explorer (#1389) @@ -195,6 +210,7 @@ Minimum VS Code version is now 1.91.0. - Provide Project and User parameters to Studio add-ins (#1402) ## [2.12.5] 29-May-2024 + - Enhancements - [Open symbol by name](https://code.visualstudio.com/docs/editor/editingevolved#_open-symbol-by-name) (`Ctrl/Cmd-T`) improvements (#1366): - Show classes as well as class members @@ -205,6 +221,7 @@ Minimum VS Code version is now 1.91.0. - Show prompt for username if unauthenticated access fails when no username is specified in the server definition (#1372) ## [2.12.4] 14-May-2024 + - Enhancements - Remove `objectscript.ignoreInstallServerManager` setting (#1339) - Make ObjectScript comment tokens configurable (#1353) @@ -216,6 +233,7 @@ Minimum VS Code version is now 1.91.0. - Support trailing slash in `isfs` directory URIs (#1357) ## [2.12.3] 26-Mar-2024 + - Enhancements - Improve `Jump to Tag + Offset` UI (#1325) - Add `Modify Project Metadata...` command (#1326) @@ -229,6 +247,7 @@ Minimum VS Code version is now 1.91.0. - Fix manual refresh of Test Explorer (#1336) (suggested by @ollitanska) ## [2.12.2] 28-Feb-2024 + - Enhancements - Add auto-closing of C-style block comments (#1311) - Server-side source control improvements (#1314): @@ -246,14 +265,17 @@ Minimum VS Code version is now 1.91.0. - Refresh ObjectScript Explorer files when they are re-opened (#1321) ## [2.12.1] 05-Feb-2024 + - Fixes - Don't create unit test items in workspace folders that don't support running tests (#1307) - Update `objectscript.unitTest.relativeTestRoots` validation regex (#1308) - Fix `undefined` errors when building array of unit tests to load (#1308) ## [2.12.0] 29-Jan-2024 + Minimum VS Code version is now 1.83.0. This extension now depends on the [InterSystems Server Manager](https://marketplace.visualstudio.com/items?itemName=intersystems-community.servermanager) extension. + - Enhancements - Add support for running and debugging unit tests (#1269) - Use Server Manager's View Container (#1270) @@ -263,7 +285,7 @@ This extension now depends on the [InterSystems Server Manager](https://marketpl - Add testing link to new KPIs (#1302) - Add CodeLenses for BPLs, DTLs, KPIs and Rules (#1303) - Fixes - - Harden `TextSearchProvider` (#1276, #1294) + - Harden `TextSearchProvider` (#1276, #1294) - Fix WebSocket Terminal del key (#1285) - Make server-side search respect Context Lines feature of Search Editor (#1290) - Better message when WebSocket Terminal can't be started (#1293) @@ -271,6 +293,7 @@ This extension now depends on the [InterSystems Server Manager](https://marketpl - Remove `glob` as a dependency (#1300) ## [2.10.5] 02-Nov-2023 + - Enhancements - Use new Modern themes when loading Studio syntax colors (#1264) - Fixes @@ -278,6 +301,7 @@ This extension now depends on the [InterSystems Server Manager](https://marketpl - Keep file contents when copying class definition if "Class" line not found (#1267) ## [2.10.4] 01-Nov-2023 + - Fixes - Fix sorting of items in Projects Explorer (#1246) - Don't show REST APIs in Explorer CSP Files list (#1248) @@ -290,6 +314,7 @@ This extension now depends on the [InterSystems Server Manager](https://marketpl - Allow users to select no resource in new KPI command (#1261) ## [2.10.3] 25-Sep-2023 + - Enhancements - Put link to editor in class comment when creating new BPL/DTL (#1231) - Make it easier to add namespace from same server to workspace (#1232) @@ -299,6 +324,7 @@ This extension now depends on the [InterSystems Server Manager](https://marketpl - Remove unneeded snippets (#1235) ## [2.10.2] 07-Sep-2023 + - Enhancements - Improve message to help resolve scenario where isfs user lacks `%DB_IRISSYS:READ` (#1211) - Improve MAC and INT stubs created for new server-side routine (#1218) @@ -309,6 +335,7 @@ This extension now depends on the [InterSystems Server Manager](https://marketpl - Display routine members of server-side project correctly (#1226) ## [2.10.1] 10-Aug-2023 + - Enhancements - Only add WebSocket Terminal button to Server Manager 3.4.2+ tree if server is compatible (#1204) - Add `Copy Invocation` CodeLens above query definition in class (#1198) @@ -318,11 +345,13 @@ This extension now depends on the [InterSystems Server Manager](https://marketpl - Properly report search matches for super classes (#1200) ## [2.10.0] 20-Jul-2023 + Minimum VS Code version is now 1.75.0 + - Enhancements - Add WebSocket Terminal support (#1150) - Support a ${project} variable in Server Actions Menu custom entries (#1157) - - Support importing/exporting XML files (#1171) + - Support importing/exporting XML files (#1171) - Support a ${username} variable in Server Actions Menu custom entries (#1173) - Migrate to [official ISC documentation](https://docs.intersystems.com/components/csp/docbook/DocBook.UI.Page.cls?KEY=GVSCO) (#1185) - Fixes @@ -344,6 +373,7 @@ Minimum VS Code version is now 1.75.0 - Upgrade vulnerable dependencies. ## [2.8.1] 15-May-2023 + - Enhancements - Prompt user to enable proposed APIs when server-side folder is opened (#1140) - Show config names of interoperability jobs in `Attach to Process` debug menu (#1089) (contributed by @ollitanska) @@ -353,6 +383,7 @@ Minimum VS Code version is now 1.75.0 - Make folder-specific settings for ISFS folder work again (#1144) ## [2.8.0] 04-Apr-2023 + - Enhancements - Integrate Angular Rule Editor (#1014) - Add command to refresh local file contents (#1066) (contributed by @ollitanska) @@ -365,6 +396,7 @@ Minimum VS Code version is now 1.75.0 - Upgrade vulnerable dependencies. ## [2.6.0] 27-Feb-2023 + - Enhancements - Implement async server-side search (#1045) (requires [proposed API enabled](https://github.com/intersystems-community/vscode-objectscript#enable-proposed-apis) and InterSystems IRIS 2023.1+) - Add `Switch Namespace` option to Server Actions menu for local workspace folders (#1065) (contributed by @ollitanska) @@ -384,11 +416,13 @@ Minimum VS Code version is now 1.75.0 - Upgrade vulnerable dependencies. ## [2.4.3] 02-Feb-2023 + - Fixes - Fix deployed check (#1071) - Fix opening of `isfs` files from ObjectScript Explorer (#1072) ## [2.4.2] 01-Feb-2023 + - Enhancements - Use query instead of index for class Deployed checks (#1054) - Use `docker compose` command if present (#1057) @@ -401,10 +435,12 @@ Minimum VS Code version is now 1.75.0 - Upgrade vulnerable dependencies. ## [2.4.1] 12-Jan-2023 + - Fixes - Fix 'No file system provider found' errors when debugging local file (#1047) ## [2.4.0] 10-Jan-2023 + - Enhancements - Show server name in Status Bar (#1017) - Server-side search: use include/exclude specs (#1021) @@ -417,10 +453,11 @@ Minimum VS Code version is now 1.75.0 - Hide `-injection` languages from selector (#1011) - Properly report matches in Storage definitions (#1025) - Fix debug breakpoint mapping when Language Server is absent (#1031) - - Don't call `openTextDocument` in debugger (#1042) + - Don't call `openTextDocument` in debugger (#1042) - Upgrade vulnerable dependencies. ## [2.2.0] 31-Oct-2022 + - Enhancements - Add features to ease migration from Studio (see [Migrating from Studio documentation page](https://docs.intersystems.com/components/csp/docbook/DocBook.UI.Page.cls?KEY=GVSCO_fromstudio) for details) (#1003) - Improve CodeLenses (#1007) @@ -432,12 +469,14 @@ Minimum VS Code version is now 1.75.0 - Upgrade vulnerable dependencies. ## [2.0.0] 04-Oct-2022 + - Enhancements - Use Server Manager version 3's enhanced security for stored passwords. Explicit permission must be given by the user before Server Manager will provide a connection's stored password to this extension. This feature previewed in the 1.x pre-releases, which 2.0.0 supersedes. - Add `Copy Invocation` CodeLens alongside `Debug this Method`. Hideable using the `objectscript.debug.copyToClipboard` setting (#974) - Add `objectscript.importOnSave` setting to control whether saving a client-side file updates code on the connected server. Default is `true` (#985) ## [1.8.2] 08-Aug-2022 + - Enhancements - Support `objectscript` and `objectscript-class` as the info string for [fenced code blocks](https://spec.commonmark.org/0.30/#fenced-code-blocks) when editing Markdown. However coloring does not render in preview (#964) - Fixes @@ -445,6 +484,7 @@ Minimum VS Code version is now 1.75.0 - Dispose of all event handlers when deactivating (#967) ## [1.8.1] 25-Jul-2022 + - Fixes - New class should ignore `objectscript.export.folder` setting (#938) - Get correct host port number for connection to docker-compose with multiple services (#941) @@ -456,13 +496,16 @@ Minimum VS Code version is now 1.75.0 - Upgrade vulnerable dependencies. ## [1.8.0] 20-Apr-2022 + - Enhancements - Add support for server-side projects (#851) - Implement isfs folder rename and deletion (#923, #922) - - Support "mapped" flag for isfs and export filters, to exclude packages mapped from other databases (#931) + - Support "mapped" flag for isfs and export filters, to exclude packages mapped from other databases (#931) ## [1.6.0] 06-Apr-2022 + Minimum VS Code version is now 1.66.0 + - Enhancements - Colorize text in Output channel (API has finalized) (#910) - Add `objectscript.export.exactFilter` setting (#913) @@ -476,6 +519,7 @@ Minimum VS Code version is now 1.66.0 - Upgrade vulnerable dependencies. ## [1.4.4] 21-Mar-2022 + - Enhancements - Compile asynchronously to avoid timing out (#890) - Add `objectscript.explorer.alwaysShowServerCopy` setting to make ObjectScript Explorer always open server-side code, even when local copy exists (#494) @@ -489,12 +533,14 @@ Minimum VS Code version is now 1.66.0 - Upgrade vulnerable dependencies. ## [1.4.3] 28-Feb-2022 + - Enhancements - Add `objectscript.openClassContracted` setting (#876) - Fixes - Fix 1.4.2 regression that broke server-side editing from ObjectScript Explorer and reloading of open documents when reopening isfs workspaces (#879) ## [1.4.2] 23-Feb-2022 + - Enhancements - Generate content when a new local class or routine is created (#867) - Add file icons (#822) @@ -510,10 +556,12 @@ Minimum VS Code version is now 1.66.0 - Upgrade vulnerable dependencies. ## [1.4.1] 14-Jan-2022 + - Fixes - Version 1.4.0 is failing to activate (#827) ## [1.4.0] 14-Jan-2022 + - Enhancements - Make `Ctrl / Cmd+T` lookup (Open Symbol by Name) check all servers connected to a multi-root workspace (#815) - Improve exporting (#818) @@ -528,10 +576,12 @@ Minimum VS Code version is now 1.66.0 - Remove `vscode-objectscript-output` language from selector (#805) ## [1.2.2] 07-Dec-2021 + - Fixes - Exporting not working with new version 1.2.1 (#799) ## [1.2.0] 02-Dec-2021 + - Enhancements - Overhaul `WorkspaceSymbolProvider` (#772) - Add `Open Shell in Docker` option to Server Actions menu (#778) @@ -545,28 +595,31 @@ Minimum VS Code version is now 1.66.0 - Upgrade vulnerable dependencies (#787) ## [1.1.1] 09-Nov-2021 + - Fixes - - Debugger: Breakpoint with no hitCondition cannot be set (#766) + - Debugger: Breakpoint with no hitCondition cannot be set (#766) ## [1.1.0] 08-Nov-2021 + - Enhancements - - Add 'Show Class Documentation Preview' button and command. - - Improve how line comment markers carry over when newline is entered (#541) - - Allow server-side source control class UserAction method call with Action=3 to launch an http/s or ftp/s URL in the external browser (contributed by @a-boertien). - - Add support for conditional breakpoints. - - Improve documentation. + - Add 'Show Class Documentation Preview' button and command. + - Improve how line comment markers carry over when newline is entered (#541) + - Allow server-side source control class UserAction method call with Action=3 to launch an http/s or ftp/s URL in the external browser (contributed by @a-boertien). + - Add support for conditional breakpoints. + - Improve documentation. - Fixes - - Prevent save of isfs class if filename doesn't match the class it defines (#410) - - Refresh ObjectScript Explorer after export (#502) - - Improve message when `/api/atelier` web application is disabled or missing (#752) - - Correctly handle dots in routine names, preventing two copies of the same routine from being opened concurrently (#748) - - Handle multiple selections when performing compile or delete from ObjectScript Explorer (#746) - - Revert document instead of attempting an undo when server-side source control signals this is necessary. - - Resolve issue causing unusable authentication page after CSP timeout. - - Fix XML to UDL conversion. - - Upgrade vulnerable dependencies. + - Prevent save of isfs class if filename doesn't match the class it defines (#410) + - Refresh ObjectScript Explorer after export (#502) + - Improve message when `/api/atelier` web application is disabled or missing (#752) + - Correctly handle dots in routine names, preventing two copies of the same routine from being opened concurrently (#748) + - Handle multiple selections when performing compile or delete from ObjectScript Explorer (#746) + - Revert document instead of attempting an undo when server-side source control signals this is necessary. + - Resolve issue causing unusable authentication page after CSP timeout. + - Fix XML to UDL conversion. + - Upgrade vulnerable dependencies. ## [1.0.14] 20-Sep-2021 + - Require confirmation before compiling all code in namespace (#677) - Respect `maxResults` parameter when running server-side search (#713) - Handle multiple spaces between `Class` keyword and classname (#717) @@ -578,6 +631,7 @@ Minimum VS Code version is now 1.66.0 - Upgrade vulnerable dependency. ## [1.0.13] 09-Jul-2021 + - Add Watchpoint support to debugging (#697) - Make QuickOpen respect any `filter=xxx` query parameter on the isfs folder definition (#593) - Fix unexpected alerts about server-side copy being newer when working with isfs (#683) @@ -588,6 +642,7 @@ Minimum VS Code version is now 1.66.0 - Allow opting out of 'Other Studio Action' server-side source control calls (#691) ## [1.0.12] 10-Jun-2021 + - Allow extension to work in untrusted workspaces. - Don't switch to File Explorer view when opening a file from ObjectScript Explorer (#651) - Scroll to correct line after an Output panel link is clicked (#657) @@ -596,6 +651,7 @@ Minimum VS Code version is now 1.66.0 - Upgrade vulnerable dependencies. ## [1.0.11] 12-May-2021 + - Support client-side web app (CSP) workflow as long as web app path is in the `/csp/*` space (#147, #449) - Add compile-only commands 'Compile Current File' and 'Compile Current File with Specified Flags...' (#595) - Add 'Edit Other' command plus menu option below 'View Other' (#309) @@ -616,6 +672,7 @@ Minimum VS Code version is now 1.66.0 - Upgrade vulnerable dependencies. ## [1.0.10] 26-Apr-2021 + - Avoid prompting for already-saved password (#61) - Constrain QuickOpen list contents when `isfs` folder path targets a specific package (#581) - Show `isfs` folder label in breadcrumb even without proposed APIs enabled (#599) @@ -628,26 +685,29 @@ Minimum VS Code version is now 1.66.0 - Upgrade vulnerable dependencies. ## [1.0.9] 22-Mar-2021 + - Allow system files (% classes) to be searched from non-%SYS namespace. - Handle `objectscript.conn.server` referencing non-existent `intersystems.servers` entry (#586) - Improve README. - Upgrade vulnerable dependencies. ## [1.0.8] 15-Jan-2021 + - Implement `isfs://server:namespace/` syntax as an alternative to the `ns=NAMESPACE` query parameter (#450) - Use new isfs notation in entries created by 'Add Server Namespace to Workspace' (#554) - Load server-side (isfs) folder-specific snippets (#552) - Improve snippets: - - Add a ///-comment tabstop at the start of all snippets used in class definitions. - - Add descriptive default text to more tabstops. - - Add third superclass to multi-superclass snippet. - - Uniformly use Capitalized command names and UPPERCASE function names in ObjectScript. - - Standardize body layout in definitions to reflect layout of result. - - Tidy how duplicate tabstops are used. + - Add a ///-comment tabstop at the start of all snippets used in class definitions. + - Add descriptive default text to more tabstops. + - Add third superclass to multi-superclass snippet. + - Uniformly use Capitalized command names and UPPERCASE function names in ObjectScript. + - Standardize body layout in definitions to reflect layout of result. + - Tidy how duplicate tabstops are used. - Support searching all Studio document types when using symbol search (Cmd/Ctrl + T). - Upgrade vulnerable dependency. ## [1.0.7] 4-Jan-2021 + - Fix issue affecting use with Docker on Windows (#516) - Resolve problem debugging in a multi-root workspace using isfs (#387) - Allow 'View Other' from custom Studio documents. @@ -656,6 +716,7 @@ Minimum VS Code version is now 1.66.0 - Upgrade vulnerable dependency. ## [1.0.6] 13-Nov-2020 + - Target current class when opening documentation from Server Actions quickpick, launched by click on ObjectScript panel in status bar (#490) - Improve code snippets (#493) - Update README to state need for manual download and install of beta VSIX in order to use proposed APIs (#478) @@ -665,26 +726,28 @@ Minimum VS Code version is now 1.66.0 - Exclude Studio project documents (.prj) from isfs tree (#501) - Fix variable tree cascade that occurred when value was edited during debugging (#505) - Show clickable url launching graphical editor for BPL and DTL documents opened from isfs folder (#508) - - To show .bpl and .dtl files, add `filter=*` to isfs folder's `uri` property in your `XXX.code-workspace` file. - - Alternatively, use `View Other` from context menu of the corresponding class. + - To show .bpl and .dtl files, add `filter=*` to isfs folder's `uri` property in your `XXX.code-workspace` file. + - Alternatively, use `View Other` from context menu of the corresponding class. - Display supported image files correctly when opened from isfs web application folder (#394) - Prevent import from overwriting class that is in [deployed mode](https://docs.intersystems.com/iris20181/csp/docbook/Doc.View.cls?KEY=GOBJ_classes#GOBJ_deploy_classes) (#382) - Respect `pathPrefix` property of an `intersystems.servers` connection definition in more places: - - debugger connections - - urls on Server Actions menu + - debugger connections + - urls on Server Actions menu ## [1.0.5] 5-Nov-2020 + - Defer to Language Server 1.0.5+ for folding range information (#473) - Add `objectscript.overwriteServerChanges` setting to permit unconditional import from local file (#464) - Fix authentication problem introduced in 1.0.2 (#458) - Handle Unicode characters in identifiers (#337) - Avoid inappropriate transfer of user-level `objectscript.conn` settings into workspace-level settings (#460) - Enhancements available only when proposed APIs are enabled: - - Improve format of results from Quick Open server-side file name search (#467) - - Add root folder label text to label of isfs file (#455) - - Add '(read-only)' suffix to label of non-editable file opened from ObjectScript Explorer (#471) + - Improve format of results from Quick Open server-side file name search (#467) + - Add root folder label text to label of isfs file (#455) + - Add '(read-only)' suffix to label of non-editable file opened from ObjectScript Explorer (#471) ## [1.0.4] 30-Oct-2020 + - Wait for connection checks to complete during activation. - Display debugging values correctly when they contain characters above ASCII 127. - Fix broken server-side .vscode storage mechanism when isfs query string includes other parameters after `ns`. @@ -694,9 +757,11 @@ Minimum VS Code version is now 1.66.0 - Differentiate "Edit" and "View" options better on isfs dialog. ## [1.0.3] 24-Oct-2020 + - Fix problem that prevented 1.0.2 from publishing to Marketplace. ## [1.0.2] 23-Oct-2020 + - Fix problem with excessive license use. - Install language server extension in the background. - Use less status bar space. @@ -704,18 +769,22 @@ Minimum VS Code version is now 1.66.0 - Add `objectscript.export.map` setting. ## [1.0.1] 20-Oct-2020 + - First production release. ## [0.9.5] + - Fix regression in 0.9.4 that broke `Add Server Namespace to Workspace...`. ## [0.9.4] + - Support folder-level settings, snippets and debug configurations for server-side (isfs) workspace folders. This feature requires a `/_vscode` webapp using the %SYS namespace. - Support webapp-type roots referencing a path that is an ancestor of one or more webapps that use the target namespace. For example `isfs://server/?ns=%SYS&csp` gives access to all %SYS webapps from a single root folder. - Enhance `Add Server Namespace to Workspace...` command and quickstart button to add webapp-type roots. - Remove requirement for namespaces to be uppercase in settings. ## [0.9.3] + - Add quickstart button to ObjectScript Explorer view when local folder is open but no `objectscript.conn` settings are available to it. - Add `Jump to Tag + Offset` command for MACs and INTs, and make it available via click on statusbar field. - Support server-side editing of other filetypes such as HL7, LUT. @@ -729,10 +798,12 @@ Minimum VS Code version is now 1.66.0 - Prepare to coexist with upcoming language server extension. ## [0.9.2] + - Implement `Add Server Namespace to Workspace...` command and surface it on folder context menus in VS Code Explorer. - Add `Choose Server and Namespace` button to VS Code Explorer view when no folder or workspace is open. This provides a quick way to get started with server-centric development, particularly when combined with the 'just-in-time' connection definition enhancement that arrived in version 0.0.7 of the Server Manager extension. ## [0.9.1] + - Fix problem that caused isfs-type saves to report incorrectly that server version was newer. - Prevent silent overwrite on retry after an import was initially canceled because of server-side difference. - Serialize and deduplicate initial credential prompting when a multi-server workspace is opened. @@ -744,6 +815,7 @@ Minimum VS Code version is now 1.66.0 - Add missing 0.9.0 CHANGELOG. ## [0.9.0] + - Change publisher id to be 'intersystems-community'. - Refresh correctly from server after isfs-type save and compile. - Swap the two sides displayed by a compare invoked after local file import conflict. Server copy is now on the left, to match convention elsewhere in VS Code. @@ -754,25 +826,27 @@ Minimum VS Code version is now 1.66.0 - Upgrade vulnerable dependencies. ## [0.8.9] + - Fix saving of isfs-type server-side editing, broken in 0.8.8. - Implement double-click opening from ObjectScript Explorer. - Make ObjectScript Explorer handle non-isfs multi-server multi-root workspace correctly. - Reload VS Code Explorer tree after successful connection. - Fix some issues with `export.addCategory` setting: - - Resolve error when non-string was used as folder value. - - If setting contains multiple patterns, check all of them, in given order. + - Resolve error when non-string was used as folder value. + - If setting contains multiple patterns, check all of them, in given order. - Fix server-side searching of isfs-type root that uses `intersystems.servers` for its connection. - - Server-side searching uses a VS Code API that is still (1.48) at "proposed" stage. See [here](https://github.com/intersystems-community/vscode-objectscript/issues/126#issuecomment-674089387) for instructions on how to use this pre-release feature. + - Server-side searching uses a VS Code API that is still (1.48) at "proposed" stage. See [here](https://github.com/intersystems-community/vscode-objectscript/issues/126#issuecomment-674089387) for instructions on how to use this pre-release feature. - No longer use progress indicator when server-side source control displays a page. - Do not call server-side AfterUserAction if not necessary. - Upgrade vulnerable dependencies. ## [0.8.8] + - Fix retrieval of password when `objectscript.conn.server` defers to Server Manager. - Fix command completions, broken in 0.8.7. - Improve ObjectScript Explorer: - - Files that will be loaded from local workspace now show their filetype icon and a full path tooltip. - - Fix rare case where code would load from wrong place. + - Files that will be loaded from local workspace now show their filetype icon and a full path tooltip. + - Fix rare case where code would load from wrong place. - Skip compilation of local CSP files for now. - Improve handling of server modification date comparisons. - Fix incorrect `Studio Action "Changed Namespace" not supported` message in output channel. @@ -781,6 +855,7 @@ Minimum VS Code version is now 1.66.0 - Improve README information about username and password in settings. ## [0.8.7] + - Use `intersystems.servers` object for more flexible connection definitions. - Recommend [intersystems-community.servermanager](https://marketplace.visualstudio.com/items?itemName=intersystems-community.servermanager) extension for management of `intersystems.servers` definitions. - Support server-side source control and other server-side commands. @@ -802,23 +877,28 @@ Minimum VS Code version is now 1.66.0 - Webpack extension to reduce size. ## [0.8.6] - 2020-04-23 + - Support $ETRAP system variable. - Fix opening Docker terminal. ## [0.8.5] - 2020-04-20 + - Fix errors in embedded JS code. - Fix diagnostic error for values in quotes. ## [0.8.3] - 2020-03-23 + - Support for custom address in isfs. - Multi select in explorer view for mass export. ## [0.8.2] - 2020-03-04 + - Show current place (label+pos^routine) in status bar for INT code. - Fix syntax highlighting. - Support for ${namespace} in links. ## [0.8.1] - 2020-02-06 + - Some small fixes in filtering for isfs. - Fixed connection info in Explorer. - Extra links for server. @@ -830,6 +910,7 @@ Minimum VS Code version is now 1.66.0 - Password prompt, live connection status. ## [0.8.0] + - "Debug this ClassMethod" feature added, to quickly debug any classmethod in a class - Change variable value while debugging - When virtual filesystem `isfs://` used, now possible to execute some actions from Studio Source class menu diff --git a/README.md b/README.md index e9cbb239..a6c7427e 100644 --- a/README.md +++ b/README.md @@ -48,23 +48,28 @@ Open VS Code. Go to Extensions view (/Ctrl+Shift { + return this.request(1, "POST", `${this.ns}/action/getglobaldocumentation`, body); + } + // v1+ public actionCompile(docs: string[], flags?: string, source = false): Promise { docs = docs.map((doc) => this.transformNameIfCsp(doc)); diff --git a/src/ccs/CHANGELOG.md b/src/ccs/CHANGELOG.md new file mode 100644 index 00000000..69299d34 --- /dev/null +++ b/src/ccs/CHANGELOG.md @@ -0,0 +1,20 @@ +# CCS Change Log + +## [3.2.0] 06-Oct-2025 + +> Based on [InterSystems VSCode ObjectScript v3.2.0 changelog](https://github.com/intersystems-community/vscode-objectscript/blob/v3.2.0/CHANGELOG.md) + +- Enhancements + - Add ObjectScript enter rules for semicolon (`;`) continuation on line break (#5) + - Auto-indent dot syntax on Enter for `objectscript`/`objectscript-int` (replicates leading dots) (#6) + - Added `resolveContextExpression` command: posts current line/routine to API, inserts returned code on success, shows error otherwise (#7) + - Reorganize CCS module structure into `src/ccs/` with separated folders for config, core, sourcecontrol, and commands (#12) + - Add `core/` and centralized `config/` scaffolds for internal module structuring (#14) + - Introduce `ContextExpressionClient` and centralized route handling for CCS API calls (#15) + - Reorganize SourceControl API into dedicated `clients/` folder (#16) + - Add Ctrl+Q to fetch global documentation from selection and print to Output (#17) + - Unify Go to Definition (F12) and Ctrl+Click through CCS API resolution (#20) +- Fixes + - Prevent unwanted semicolon insertion on ObjectScript line breaks (#13) + - Fix prettier `Insert enter` error in ObjectScript editor rules (#10) + - Ensure consistent indentation and formatting for `.mac` and `.int` routines (#11) diff --git a/src/ccs/commands/contextHelp.ts b/src/ccs/commands/contextHelp.ts new file mode 100644 index 00000000..40861e76 --- /dev/null +++ b/src/ccs/commands/contextHelp.ts @@ -0,0 +1,48 @@ +import * as path from "path"; +import * as vscode from "vscode"; + +import { ContextExpressionClient } from "../sourcecontrol/clients/contextExpressionClient"; +import { handleError } from "../../utils"; + +const sharedClient = new ContextExpressionClient(); + +export async function resolveContextExpression(): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + const { document, selection } = editor; + const contextExpression = selection.isEmpty + ? document.lineAt(selection.active.line).text.trim() + : document.getText(selection).trim(); + + if (!contextExpression) { + void vscode.window.showErrorMessage("Context expression is empty."); + return; + } + + const routine = path.basename(document.fileName); + + try { + const response = await sharedClient.resolve(document, { routine, contextExpression }); + const data = response ?? {}; + + if (typeof data.status === "string" && data.status.toLowerCase() === "success" && data.textExpression) { + const eol = document.eol === vscode.EndOfLine.CRLF ? "\r\n" : "\n"; + const textExpression = data.textExpression.replace(/\r?\n/g, eol); + const formattedTextExpression = textExpression.replace(/^/, "\t"); + const rangeToReplace = selection.isEmpty + ? document.lineAt(selection.active.line).range + : new vscode.Range(selection.start, selection.end); + await editor.edit((editBuilder) => { + editBuilder.replace(rangeToReplace, formattedTextExpression); + }); + } else { + const errorMessage = data.message || "Failed to resolve context expression."; + void vscode.window.showErrorMessage(errorMessage); + } + } catch (error) { + handleError(error, "Failed to resolve context expression."); + } +} diff --git a/src/ccs/commands/globalDocumentation.ts b/src/ccs/commands/globalDocumentation.ts new file mode 100644 index 00000000..938481f7 --- /dev/null +++ b/src/ccs/commands/globalDocumentation.ts @@ -0,0 +1,48 @@ +import * as vscode from "vscode"; + +import { GlobalDocumentationClient } from "../sourcecontrol/clients/globalDocumentationClient"; +import { handleError, outputChannel } from "../../utils"; + +const sharedClient = new GlobalDocumentationClient(); + +function getSelectedOrCurrentLineText(editor: vscode.TextEditor): string { + const { selection, document } = editor; + + if (!selection || selection.isEmpty) { + return document.lineAt(selection.active.line).text.trim(); + } + + return document.getText(selection).trim(); +} + +export async function showGlobalDocumentation(): Promise { + const editor = vscode.window.activeTextEditor; + + if (!editor) { + return; + } + + const selectedText = getSelectedOrCurrentLineText(editor); + + if (!selectedText) { + void vscode.window.showErrorMessage("Selection is empty. Select text or place the cursor on a line with content."); + return; + } + + try { + const content = await sharedClient.fetch(editor.document, { selectedText }); + + if (!content || !content.trim()) { + void vscode.window.showInformationMessage("Global documentation did not return any content."); + return; + } + + outputChannel.appendLine("==================== Global Documentation ===================="); + for (const line of content.split(/\r?\n/)) { + outputChannel.appendLine(line); + } + outputChannel.show(true); + } catch (error) { + handleError(error, "Failed to retrieve global documentation."); + } +} diff --git a/src/ccs/commands/goToDefinitionLocalFirst.ts b/src/ccs/commands/goToDefinitionLocalFirst.ts new file mode 100644 index 00000000..31d48b38 --- /dev/null +++ b/src/ccs/commands/goToDefinitionLocalFirst.ts @@ -0,0 +1,26 @@ +import * as vscode from "vscode"; + +import { lookupCcsDefinition } from "../features/definitionLookup/lookup"; + +export async function goToDefinitionLocalFirst(): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + const { document, selection } = editor; + const position = selection.active; + const tokenSource = new vscode.CancellationTokenSource(); + + try { + const location = await lookupCcsDefinition(document, position, tokenSource.token); + if (location) { + await vscode.window.showTextDocument(location.uri, { selection: location.range }); + return; + } + } finally { + tokenSource.dispose(); + } + + await vscode.commands.executeCommand("editor.action.revealDefinition"); +} diff --git a/src/ccs/config/schema.md b/src/ccs/config/schema.md new file mode 100644 index 00000000..34d7f689 --- /dev/null +++ b/src/ccs/config/schema.md @@ -0,0 +1,13 @@ +# Configuração do módulo CCS + +As opções abaixo ficam no escopo `objectscript.ccs` e controlam as integrações específicas +para o fork da Consistem. + +| Chave | Tipo | Padrão | Descrição | +| ---------------- | ------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------- | +| `endpoint` | `string` | `undefined` | URL base alternativa para a API. Se não definida, a URL é derivada da conexão ativa do Atelier. | +| `requestTimeout` | `number` | `500` | Tempo limite (ms) aplicado às chamadas HTTP do módulo. Valores menores ou inválidos são normalizados para zero. | +| `debugLogging` | `boolean` | `false` | Quando verdadeiro, registra mensagens detalhadas no `ObjectScript` Output Channel. | +| `flags` | `Record` | `{}` | Feature flags opcionais que podem ser lidas pelas features do módulo. | + +Essas configurações não exigem reload da janela; toda leitura é feita sob demanda. diff --git a/src/ccs/config/settings.ts b/src/ccs/config/settings.ts new file mode 100644 index 00000000..e641142e --- /dev/null +++ b/src/ccs/config/settings.ts @@ -0,0 +1,51 @@ +import * as vscode from "vscode"; + +export interface CcsSettings { + endpoint?: string; + requestTimeout: number; + debugLogging: boolean; + flags: Record; +} + +const CCS_CONFIGURATION_SECTION = "objectscript.ccs"; +const DEFAULT_TIMEOUT = 500; + +export function getCcsSettings(): CcsSettings { + const configuration = vscode.workspace.getConfiguration(CCS_CONFIGURATION_SECTION); + const endpoint = sanitizeEndpoint(configuration.get("endpoint")); + const requestTimeout = coerceTimeout(configuration.get("requestTimeout")); + const debugLogging = Boolean(configuration.get("debugLogging")); + const flags = configuration.get>("flags") ?? {}; + + return { + endpoint, + requestTimeout, + debugLogging, + flags, + }; +} + +export function isFlagEnabled(flag: string, settings: CcsSettings = getCcsSettings()): boolean { + return Boolean(settings.flags?.[flag]); +} + +function sanitizeEndpoint(endpoint?: string): string | undefined { + if (!endpoint) { + return undefined; + } + + const trimmed = endpoint.trim(); + if (!trimmed) { + return undefined; + } + + return trimmed.replace(/\/+$/, ""); +} + +function coerceTimeout(timeout: number | undefined): number { + if (typeof timeout !== "number" || Number.isNaN(timeout)) { + return DEFAULT_TIMEOUT; + } + + return Math.max(0, Math.floor(timeout)); +} diff --git a/src/ccs/core/http.ts b/src/ccs/core/http.ts new file mode 100644 index 00000000..50254aea --- /dev/null +++ b/src/ccs/core/http.ts @@ -0,0 +1,81 @@ +import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from "axios"; +import * as https from "https"; +import * as vscode from "vscode"; + +import { logDebug, logError } from "./logging"; +import { getCcsSettings } from "../config/settings"; + +interface CreateClientOptions { + baseURL: string; + auth?: AxiosRequestConfig["auth"]; + defaultTimeout?: number; +} + +export function createHttpClient(options: CreateClientOptions): AxiosInstance { + const { baseURL, auth, defaultTimeout } = options; + const strictSSL = vscode.workspace.getConfiguration("http").get("proxyStrictSSL"); + const httpsAgent = new https.Agent({ rejectUnauthorized: strictSSL }); + const timeout = typeof defaultTimeout === "number" ? defaultTimeout : getCcsSettings().requestTimeout; + + const client = axios.create({ + baseURL, + auth, + timeout, + headers: { "Content-Type": "application/json" }, + httpsAgent, + }); + + attachLogging(client); + + return client; +} + +function attachLogging(client: AxiosInstance): void { + client.interceptors.request.use((config) => { + logDebug(`HTTP ${config.method?.toUpperCase()} ${resolveFullUrl(client, config)}`); + return config; + }); + + client.interceptors.response.use( + (response) => { + logDebug(`HTTP ${response.status} ${resolveFullUrl(client, response.config)}`); + return response; + }, + (error: AxiosError) => { + if (axios.isCancel(error)) { + logDebug("HTTP request cancelled"); + return Promise.reject(error); + } + + const status = error.response?.status; + const url = resolveFullUrl(client, error.config ?? {}); + const message = typeof status === "number" ? `HTTP ${status} ${url}` : `HTTP request failed ${url}`; + logError(message, error); + return Promise.reject(error); + } + ); +} + +function resolveFullUrl(client: AxiosInstance, config: AxiosRequestConfig | InternalAxiosRequestConfig): string { + const base = config.baseURL ?? client.defaults.baseURL ?? ""; + const url = config.url ?? ""; + if (!base) { + return url; + } + + if (/^https?:/i.test(url)) { + return url; + } + + return `${base}${url}`; +} + +export function createAbortSignal(token: vscode.CancellationToken): { signal: AbortSignal; dispose: () => void } { + const controller = new AbortController(); + const subscription = token.onCancellationRequested(() => controller.abort()); + + return { + signal: controller.signal, + dispose: () => subscription.dispose(), + }; +} diff --git a/src/ccs/core/logging.ts b/src/ccs/core/logging.ts new file mode 100644 index 00000000..4c23968b --- /dev/null +++ b/src/ccs/core/logging.ts @@ -0,0 +1,52 @@ +import { inspect } from "util"; + +import { outputChannel } from "../../utils"; +import { getCcsSettings } from "../config/settings"; + +type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR"; + +const PREFIX = "[CCS]"; + +export function logDebug(message: string, ...details: unknown[]): void { + if (!getCcsSettings().debugLogging) { + return; + } + writeLog("DEBUG", message, details); +} + +export function logInfo(message: string, ...details: unknown[]): void { + writeLog("INFO", message, details); +} + +export function logWarn(message: string, ...details: unknown[]): void { + writeLog("WARN", message, details); +} + +export function logError(message: string, error?: unknown): void { + const details = error ? [formatError(error)] : []; + writeLog("ERROR", message, details); +} + +function writeLog(level: LogLevel, message: string, details: unknown[]): void { + const timestamp = new Date().toISOString(); + outputChannel.appendLine(`${PREFIX} ${timestamp} ${level}: ${message}`); + if (details.length > 0) { + for (const detail of details) { + outputChannel.appendLine(`${PREFIX} ${stringify(detail)}`); + } + } +} + +function stringify(value: unknown): string { + if (typeof value === "string") { + return value; + } + return inspect(value, { depth: 4, breakLength: Infinity }); +} + +function formatError(error: unknown): string { + if (error instanceof Error) { + return `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ""}`; + } + return stringify(error); +} diff --git a/src/ccs/core/types.ts b/src/ccs/core/types.ts new file mode 100644 index 00000000..707eb8ba --- /dev/null +++ b/src/ccs/core/types.ts @@ -0,0 +1,21 @@ +export interface LocationJSON { + uri?: string; + line?: number; +} + +export type ResolveDefinitionResponse = LocationJSON; + +export interface ResolveContextExpressionResponse { + status?: string; + textExpression?: string; + message?: string; +} + +export interface SourceControlError { + message: string; + cause?: unknown; +} +export interface GlobalDocumentationResponse { + content?: string | string[] | Record | null; + message?: string; +} diff --git a/src/ccs/features/definitionLookup/extractQuery.ts b/src/ccs/features/definitionLookup/extractQuery.ts new file mode 100644 index 00000000..4d214c09 --- /dev/null +++ b/src/ccs/features/definitionLookup/extractQuery.ts @@ -0,0 +1,311 @@ +import * as vscode from "vscode"; + +export type QueryKind = "labelRoutine" | "routine" | "macro" | "class"; + +export interface QueryMatch { + query: string; + normalizedQuery: string; + kind: QueryKind; + symbolName?: string; + range: vscode.Range; +} + +type DefinitionToken = QueryMatch & { activationRange: vscode.Range }; + +const LABEL_ROUTINE_REGEX = /\$\$([%A-Za-z][\w]*)\^([%A-Za-z][\w]*(?:\.[%A-Za-z][\w]*)*)/g; +const ROUTINE_INVOCATION_KEYWORDS = ["do", "job"]; +const ROUTINE_INVOCATION_PATTERN = ROUTINE_INVOCATION_KEYWORDS.join("|"); +const COMMAND_LABEL_ROUTINE_REGEX = new RegExp( + `\\b(?:${ROUTINE_INVOCATION_PATTERN})\\b\\s+([%A-Za-z][\\w]*)\\^([%A-Za-z][\\w]*(?:\\.[%A-Za-z][\\w]*)*)`, + "gi" +); +const COMMAND_ROUTINE_REGEX = new RegExp( + `\\b(?:${ROUTINE_INVOCATION_PATTERN})\\b\\s+\\^([%A-Za-z][\\w]*(?:\\.[%A-Za-z][\\w]*)*)`, + "gi" +); +const MACRO_REGEX = /\${3}([%A-Za-z][%A-Za-z0-9_]*)/g; +const CLASS_REFERENCE_REGEX = new RegExp( + "##class\\s*\\(\\s*([%A-Za-z][\\w]*(?:\\.[%A-Za-z][\\w]*)*)\\s*\\)(?:\\s*\\.\\s*([%A-Za-z][\\w]*))?", + "gi" +); + +export function extractDefinitionQuery( + document: vscode.TextDocument, + position: vscode.Position +): QueryMatch | undefined { + const line = position.line; + const lineText = document.lineAt(line).text; + const tokens = collectDefinitionTokens(lineText, line); + + const directMatch = tokens.find((token) => containsPosition(token.range, position)); + if (directMatch) { + return withoutActivationRange(directMatch); + } + + const activationMatch = tokens.find((token) => containsPosition(token.activationRange, position)); + if (activationMatch) { + return withoutActivationRange(activationMatch); + } + + return undefined; +} + +export function extractDefinitionQueries(document: vscode.TextDocument): QueryMatch[] { + const matches: QueryMatch[] = []; + for (let line = 0; line < document.lineCount; line++) { + const lineText = document.lineAt(line).text; + const tokens = collectDefinitionTokens(lineText, line); + for (const token of tokens) { + matches.push(withoutActivationRange(token)); + } + } + return matches; +} + +interface MatchContext { + line: number; + start: number; + text: string; + match: RegExpExecArray; +} + +interface DefinitionMatcher { + regex: RegExp; + buildTokens(context: MatchContext): DefinitionToken[]; +} + +const MATCHERS: DefinitionMatcher[] = [ + { + regex: LABEL_ROUTINE_REGEX, + buildTokens: ({ line, start, text, match }) => { + const [, labelName, routineName] = match; + const normalized = `${labelName}^${routineName}`; + const labelStart = start + 2; + const labelEnd = labelStart + labelName.length; + const caretIndex = text.indexOf("^"); + if (caretIndex < 0) { + return []; + } + const caretColumn = start + caretIndex; + const routineStart = caretColumn + 1; + const routineEnd = routineStart + routineName.length; + + return [ + createToken({ + line, + start: labelStart, + end: labelEnd, + query: text, + normalizedQuery: normalized, + kind: "labelRoutine", + symbolName: routineName, + }), + createToken({ + line, + start: routineStart, + end: routineEnd, + activationStart: caretColumn, + query: `^${routineName}`, + normalizedQuery: `^${routineName}`, + kind: "routine", + symbolName: routineName, + }), + ]; + }, + }, + { + regex: COMMAND_LABEL_ROUTINE_REGEX, + buildTokens: ({ line, start, text, match }) => { + const [, labelName, routineName] = match; + const normalized = `${labelName}^${routineName}`; + const labelOffset = text.indexOf(labelName); + if (labelOffset < 0) { + return []; + } + const labelStart = start + labelOffset; + const labelEnd = labelStart + labelName.length; + const caretIndex = text.indexOf("^"); + if (caretIndex < 0) { + return []; + } + const caretColumn = start + caretIndex; + const routineOffset = text.lastIndexOf(routineName); + if (routineOffset < 0) { + return []; + } + const routineStart = start + routineOffset; + const routineEnd = routineStart + routineName.length; + + return [ + createToken({ + line, + start: labelStart, + end: labelEnd, + query: normalized, + normalizedQuery: normalized, + kind: "labelRoutine", + symbolName: routineName, + }), + createToken({ + line, + start: routineStart, + end: routineEnd, + activationStart: caretColumn, + query: `^${routineName}`, + normalizedQuery: `^${routineName}`, + kind: "routine", + symbolName: routineName, + }), + ]; + }, + }, + { + regex: COMMAND_ROUTINE_REGEX, + buildTokens: ({ line, start, text, match }) => { + const [, routineName] = match; + const caretIndex = text.indexOf("^"); + if (caretIndex < 0) { + return []; + } + const caretColumn = start + caretIndex; + const routineOffset = text.lastIndexOf(routineName); + if (routineOffset < 0) { + return []; + } + const routineStart = start + routineOffset; + const routineEnd = routineStart + routineName.length; + + return [ + createToken({ + line, + start: routineStart, + end: routineEnd, + activationStart: caretColumn, + query: `^${routineName}`, + normalizedQuery: `^${routineName}`, + kind: "routine", + symbolName: routineName, + }), + ]; + }, + }, + { + regex: MACRO_REGEX, + buildTokens: ({ line, start, text, match }) => { + const [, macroName] = match; + const macroStart = start + (text.length - macroName.length); + if (macroStart < start) { + return []; + } + const macroEnd = macroStart + macroName.length; + + return [ + createToken({ + line, + start: macroStart, + end: macroEnd, + activationStart: start, + query: text, + normalizedQuery: text, + kind: "macro", + symbolName: macroName, + }), + ]; + }, + }, + { + regex: CLASS_REFERENCE_REGEX, + buildTokens: ({ line, start, text, match }) => { + const [, className, methodName] = match; + const classOffset = text.indexOf(className); + if (classOffset < 0) { + return []; + } + const classStart = start + classOffset; + const classEnd = classStart + className.length; + const tokens: DefinitionToken[] = [ + createToken({ + line, + start: classStart, + end: classEnd, + query: `##class(${className})`, + normalizedQuery: `##class(${className})`, + kind: "class", + symbolName: className, + }), + ]; + + if (methodName) { + const methodOffset = text.lastIndexOf(methodName); + if (methodOffset < 0) { + return tokens; + } + const methodStart = start + methodOffset; + const methodEnd = methodStart + methodName.length; + tokens.push( + createToken({ + line, + start: methodStart, + end: methodEnd, + query: `##class(${className}).${methodName}`, + normalizedQuery: `##class(${className}).${methodName}`, + kind: "class", + symbolName: className, + }) + ); + } + + return tokens; + }, + }, +]; + +function collectDefinitionTokens(lineText: string, line: number): DefinitionToken[] { + const tokens: DefinitionToken[] = []; + for (const matcher of MATCHERS) { + const regex = cloneRegex(matcher.regex); + let match: RegExpExecArray | null; + while ((match = regex.exec(lineText)) !== null) { + tokens.push(...matcher.buildTokens({ line, start: match.index, text: match[0], match })); + if (!regex.global) { + break; + } + } + } + return tokens; +} + +function createToken(options: { + line: number; + start: number; + end: number; + activationStart?: number; + query: string; + normalizedQuery: string; + kind: QueryKind; + symbolName?: string; +}): DefinitionToken { + const { line, start, end, activationStart = start } = options; + const activationEnd = Math.max(end, activationStart + 1); + return { + query: options.query, + normalizedQuery: options.normalizedQuery, + kind: options.kind, + symbolName: options.symbolName, + range: new vscode.Range(line, start, line, end), + activationRange: new vscode.Range(line, activationStart, line, activationEnd), + }; +} + +function withoutActivationRange(token: DefinitionToken): QueryMatch { + const { activationRange: _activationRange, ...rest } = token; + return rest; +} + +function containsPosition(range: vscode.Range, position: vscode.Position): boolean { + return position.isAfterOrEqual(range.start) && position.isBefore(range.end); +} + +function cloneRegex(regex: RegExp): RegExp { + return new RegExp(regex.source, regex.flags); +} diff --git a/src/ccs/features/definitionLookup/lookup.ts b/src/ccs/features/definitionLookup/lookup.ts new file mode 100644 index 00000000..7513bf58 --- /dev/null +++ b/src/ccs/features/definitionLookup/lookup.ts @@ -0,0 +1,82 @@ +import * as vscode from "vscode"; + +import { ResolveDefinitionClient } from "../../sourcecontrol/clients/resolveDefinitionClient"; +import { currentFile, CurrentTextFile } from "../../../utils"; +import { extractDefinitionQuery, QueryMatch } from "./extractQuery"; + +const sharedClient = new ResolveDefinitionClient(); + +export interface LookupOptions { + client?: ResolveDefinitionClient; + onNoResult?: (details: { query: string; originalQuery?: string }) => void; +} + +export async function lookupCcsDefinition( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken, + options: LookupOptions = {} +): Promise { + const match = extractDefinitionQuery(document, position); + if (!match) { + return undefined; + } + + if (!shouldUseExternalResolver(document, match)) { + return undefined; + } + + const client = options.client ?? sharedClient; + const location = await client.resolve(document, match.normalizedQuery, token); + if (!location) { + options.onNoResult?.({ query: match.normalizedQuery, originalQuery: match.query }); + } + return location; +} + +function shouldUseExternalResolver(document: vscode.TextDocument, match: QueryMatch): boolean { + const current = currentFile(document); + if (!current) { + return true; + } + + switch (match.kind) { + case "macro": + return !hasLocalMacroDefinition(document, match.symbolName); + case "class": + return !isCurrentClass(current, match.symbolName); + case "labelRoutine": + case "routine": + return !isCurrentRoutine(current, match.symbolName); + default: + return true; + } +} + +function hasLocalMacroDefinition(document: vscode.TextDocument, macroName?: string): boolean { + if (!macroName) { + return false; + } + const regex = new RegExp(`^[\t ]*#def(?:ine|1arg)\\s+${macroName}\\b`, "mi"); + return regex.test(document.getText()); +} + +function isCurrentClass(current: CurrentTextFile, target?: string): boolean { + if (!target || !current.name.toLowerCase().endsWith(".cls")) { + return false; + } + const currentClassName = current.name.slice(0, -4); + return currentClassName.toLowerCase() === target.toLowerCase(); +} + +function isCurrentRoutine(current: CurrentTextFile, target?: string): boolean { + if (!target) { + return false; + } + const routineMatch = current.name.match(/^(.*)\.(mac|int|inc)$/i); + if (!routineMatch) { + return false; + } + const [, routineName] = routineMatch; + return routineName.toLowerCase() === target.toLowerCase(); +} diff --git a/src/ccs/index.ts b/src/ccs/index.ts new file mode 100644 index 00000000..88212849 --- /dev/null +++ b/src/ccs/index.ts @@ -0,0 +1,21 @@ +export { getCcsSettings, isFlagEnabled, type CcsSettings } from "./config/settings"; +export { logDebug, logError, logInfo, logWarn } from "./core/logging"; +export { SourceControlApi } from "./sourcecontrol/client"; +export { resolveContextExpression } from "./commands/contextHelp"; +export { showGlobalDocumentation } from "./commands/globalDocumentation"; +export { ContextExpressionClient } from "./sourcecontrol/clients/contextExpressionClient"; +export { GlobalDocumentationClient } from "./sourcecontrol/clients/globalDocumentationClient"; +export { ResolveDefinitionClient } from "./sourcecontrol/clients/resolveDefinitionClient"; +export { lookupCcsDefinition, type LookupOptions } from "./features/definitionLookup/lookup"; +export { + extractDefinitionQuery, + extractDefinitionQueries, + type QueryMatch, + type QueryKind, +} from "./features/definitionLookup/extractQuery"; +export { goToDefinitionLocalFirst } from "./commands/goToDefinitionLocalFirst"; +export { PrioritizedDefinitionProvider } from "./providers/PrioritizedDefinitionProvider"; +export { + DefinitionDocumentLinkProvider, + followDefinitionLinkCommand, +} from "./providers/DefinitionDocumentLinkProvider"; diff --git a/src/ccs/providers/DefinitionDocumentLinkProvider.ts b/src/ccs/providers/DefinitionDocumentLinkProvider.ts new file mode 100644 index 00000000..7fea9574 --- /dev/null +++ b/src/ccs/providers/DefinitionDocumentLinkProvider.ts @@ -0,0 +1,189 @@ +import * as vscode from "vscode"; + +import { extractDefinitionQueries } from "../features/definitionLookup/extractQuery"; + +export const followDefinitionLinkCommand = "vscode-objectscript.ccs.followDefinitionLink"; + +type TimeoutHandle = ReturnType; + +export class DefinitionDocumentLinkProvider implements vscode.DocumentLinkProvider, vscode.Disposable { + private readonly decorationType = vscode.window.createTextEditorDecorationType({ + textDecoration: "none", + }); + + private readonly _onDidChange = new vscode.EventEmitter(); + + public readonly onDidChange: vscode.Event = this._onDidChange.event; + + private readonly supportedLanguages?: Set; + + private readonly subscriptions: vscode.Disposable[] = []; + + private readonly linkRanges = new Map(); + + private readonly refreshTimeouts = new Map(); + + constructor(supportedLanguages?: readonly string[]) { + this.supportedLanguages = supportedLanguages?.length ? new Set(supportedLanguages) : undefined; + + this.subscriptions.push( + vscode.window.onDidChangeVisibleTextEditors(() => this.handleVisibleEditorsChange()), + vscode.window.onDidChangeActiveTextEditor(() => this.handleVisibleEditorsChange()), + vscode.workspace.onDidChangeTextDocument((event) => { + if (this.shouldHandleDocument(event.document)) { + this.scheduleRefresh(event.document); + } + }), + vscode.workspace.onDidCloseTextDocument((document) => this.clearDocument(document)) + ); + + this.handleVisibleEditorsChange(); + } + + public provideDocumentLinks(document: vscode.TextDocument): vscode.DocumentLink[] { + const queries = extractDefinitionQueries(document); + this.updateDocumentRanges( + document, + queries.map((match) => match.range) + ); + + return queries.map((match) => { + const args = [document.uri.toString(), match.range.start.line, match.range.start.character]; + const commandUri = vscode.Uri.parse( + `command:${followDefinitionLinkCommand}?${encodeURIComponent(JSON.stringify(args))}` + ); + const link = new vscode.DocumentLink(match.range, commandUri); + link.tooltip = vscode.l10n.t("Go to Definition"); + return link; + }); + } + + public dispose(): void { + for (const timeout of this.refreshTimeouts.values()) { + clearTimeout(timeout); + } + this.refreshTimeouts.clear(); + + for (const disposable of this.subscriptions) { + disposable.dispose(); + } + + for (const editor of vscode.window.visibleTextEditors) { + editor.setDecorations(this.decorationType, []); + } + + this.linkRanges.clear(); + this.decorationType.dispose(); + this._onDidChange.dispose(); + } + + private handleVisibleEditorsChange(): void { + const visibleDocuments = new Set(); + + for (const editor of vscode.window.visibleTextEditors) { + if (!this.shouldHandleDocument(editor.document)) { + editor.setDecorations(this.decorationType, []); + continue; + } + + const key = editor.document.uri.toString(); + visibleDocuments.add(key); + + const ranges = this.linkRanges.get(key); + if (ranges) { + editor.setDecorations(this.decorationType, ranges); + } else { + editor.setDecorations(this.decorationType, []); + this.scheduleRefresh(editor.document); + } + } + + for (const key of [...this.linkRanges.keys()]) { + if (!visibleDocuments.has(key)) { + this.linkRanges.delete(key); + } + } + } + + private scheduleRefresh(document: vscode.TextDocument): void { + if (document.isClosed || !this.shouldHandleDocument(document)) { + return; + } + + const key = document.uri.toString(); + const existing = this.refreshTimeouts.get(key); + if (existing) { + clearTimeout(existing); + } + + const timeout = setTimeout(() => { + this.refreshTimeouts.delete(key); + if (document.isClosed) { + this.clearDocumentByKey(key); + return; + } + const queries = extractDefinitionQueries(document); + this.updateDocumentRanges( + document, + queries.map((match) => match.range) + ); + + this._onDidChange.fire(); + }, 50); + + this.refreshTimeouts.set(key, timeout); + } + + private updateDocumentRanges(document: vscode.TextDocument, ranges: vscode.Range[]): void { + const key = document.uri.toString(); + + const existing = this.refreshTimeouts.get(key); + if (existing) { + clearTimeout(existing); + this.refreshTimeouts.delete(key); + } + + if (ranges.length > 0) { + this.linkRanges.set(key, ranges); + } else { + this.linkRanges.delete(key); + } + + this.applyDecorationsForKey(key, ranges); + } + + private clearDocument(document: vscode.TextDocument): void { + this.clearDocumentByKey(document.uri.toString()); + } + + private clearDocumentByKey(key: string): void { + const timeout = this.refreshTimeouts.get(key); + if (timeout) { + clearTimeout(timeout); + this.refreshTimeouts.delete(key); + } + + this.linkRanges.delete(key); + this.applyDecorationsForKey(key, []); + } + + private applyDecorationsForKey(key: string, ranges: vscode.Range[]): void { + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.uri.toString() === key) { + editor.setDecorations(this.decorationType, ranges); + } + } + } + + private shouldHandleDocument(document: vscode.TextDocument): boolean { + if (document.isClosed) { + return false; + } + + if (this.supportedLanguages && !this.supportedLanguages.has(document.languageId)) { + return false; + } + + return true; + } +} diff --git a/src/ccs/providers/PrioritizedDefinitionProvider.ts b/src/ccs/providers/PrioritizedDefinitionProvider.ts new file mode 100644 index 00000000..fdffc809 --- /dev/null +++ b/src/ccs/providers/PrioritizedDefinitionProvider.ts @@ -0,0 +1,34 @@ +import * as vscode from "vscode"; + +import { ObjectScriptDefinitionProvider } from "../../providers/ObjectScriptDefinitionProvider"; +import { lookupCcsDefinition } from "../features/definitionLookup/lookup"; + +export class PrioritizedDefinitionProvider implements vscode.DefinitionProvider { + private readonly delegate: ObjectScriptDefinitionProvider; + private readonly lookup: typeof lookupCcsDefinition; + + public constructor( + delegate: ObjectScriptDefinitionProvider, + lookupFn: typeof lookupCcsDefinition = lookupCcsDefinition + ) { + this.delegate = delegate; + this.lookup = lookupFn; + } + + public async provideDefinition( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken + ): Promise { + const location = await this.lookup(document, position, token, { + onNoResult: () => { + // No result from CCS resolver, fallback will be triggered + }, + }); + if (location) { + return location; + } + + return this.delegate.provideDefinition(document, position, token); + } +} diff --git a/src/ccs/sourcecontrol/client.ts b/src/ccs/sourcecontrol/client.ts new file mode 100644 index 00000000..ba4c05af --- /dev/null +++ b/src/ccs/sourcecontrol/client.ts @@ -0,0 +1,57 @@ +import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; + +import { AtelierAPI } from "../../api"; +import { getCcsSettings } from "../config/settings"; +import { createHttpClient } from "../core/http"; +import { logDebug } from "../core/logging"; +import { BASE_PATH } from "./routes"; + +export class SourceControlApi { + private readonly client: AxiosInstance; + + private constructor(client: AxiosInstance) { + this.client = client; + } + + public static fromAtelierApi(api: AtelierAPI): SourceControlApi { + const { host, port, username, password, https: useHttps, pathPrefix } = api.config; + + if (!host || !port) { + throw new Error("No active InterSystems server connection for this file."); + } + + const normalizedPrefix = pathPrefix ? (pathPrefix.startsWith("/") ? pathPrefix : `/${pathPrefix}`) : ""; + const trimmedPrefix = normalizedPrefix.endsWith("/") ? normalizedPrefix.slice(0, -1) : normalizedPrefix; + const encodedPrefix = encodeURI(trimmedPrefix); + const protocol = useHttps ? "https" : "http"; + const defaultBaseUrl = `${protocol}://${host}:${port}${encodedPrefix}${BASE_PATH}`; + + const { endpoint, requestTimeout } = getCcsSettings(); + const baseURL = endpoint ?? defaultBaseUrl; + const auth = + typeof username === "string" && typeof password === "string" + ? { + username, + password, + } + : undefined; + + logDebug("Creating SourceControl API client", { baseURL, hasAuth: Boolean(auth) }); + + const client = createHttpClient({ + baseURL, + auth, + defaultTimeout: requestTimeout, + }); + + return new SourceControlApi(client); + } + + public post>( + route: string, + data?: unknown, + config?: AxiosRequestConfig + ): Promise { + return this.client.post(route, data, config); + } +} diff --git a/src/ccs/sourcecontrol/clients/contextExpressionClient.ts b/src/ccs/sourcecontrol/clients/contextExpressionClient.ts new file mode 100644 index 00000000..50461039 --- /dev/null +++ b/src/ccs/sourcecontrol/clients/contextExpressionClient.ts @@ -0,0 +1,54 @@ +import * as vscode from "vscode"; + +import { AtelierAPI } from "../../../api"; +import { getCcsSettings } from "../../config/settings"; +import { logDebug } from "../../core/logging"; +import { ResolveContextExpressionResponse } from "../../core/types"; +import { SourceControlApi } from "../client"; +import { ROUTES } from "../routes"; + +interface ResolveContextExpressionPayload { + routine: string; + contextExpression: string; +} + +export class ContextExpressionClient { + private readonly apiFactory: (api: AtelierAPI) => SourceControlApi; + + public constructor(apiFactory: (api: AtelierAPI) => SourceControlApi = SourceControlApi.fromAtelierApi) { + this.apiFactory = apiFactory; + } + + public async resolve( + document: vscode.TextDocument, + payload: ResolveContextExpressionPayload + ): Promise { + const api = new AtelierAPI(document.uri); + + let sourceControlApi: SourceControlApi; + try { + sourceControlApi = this.apiFactory(api); + } catch (error) { + logDebug("Failed to create SourceControl API client for context expression", error); + throw error; + } + + const { requestTimeout } = getCcsSettings(); + + try { + const response = await sourceControlApi.post( + ROUTES.resolveContextExpression(), + payload, + { + timeout: requestTimeout, + validateStatus: (status) => status >= 200 && status < 300, + } + ); + + return response.data ?? {}; + } catch (error) { + logDebug("Context expression resolution failed", error); + throw error; + } + } +} diff --git a/src/ccs/sourcecontrol/clients/globalDocumentationClient.ts b/src/ccs/sourcecontrol/clients/globalDocumentationClient.ts new file mode 100644 index 00000000..6c779661 --- /dev/null +++ b/src/ccs/sourcecontrol/clients/globalDocumentationClient.ts @@ -0,0 +1,47 @@ +import * as vscode from "vscode"; + +import { AtelierAPI } from "../../../api"; +import { getCcsSettings } from "../../config/settings"; +import { logDebug } from "../../core/logging"; +import { SourceControlApi } from "../client"; +import { ROUTES } from "../routes"; + +interface GlobalDocumentationPayload { + selectedText: string; +} + +export class GlobalDocumentationClient { + private readonly apiFactory: (api: AtelierAPI) => SourceControlApi; + + public constructor(apiFactory: (api: AtelierAPI) => SourceControlApi = SourceControlApi.fromAtelierApi) { + this.apiFactory = apiFactory; + } + + public async fetch(document: vscode.TextDocument, payload: GlobalDocumentationPayload): Promise { + const api = new AtelierAPI(document.uri); + + let sourceControlApi: SourceControlApi; + try { + sourceControlApi = this.apiFactory(api); + } catch (error) { + logDebug("Failed to create SourceControl API client for global documentation", error); + throw error; + } + + const { requestTimeout } = getCcsSettings(); + + try { + const response = await sourceControlApi.post(ROUTES.getGlobalDocumentation(), payload, { + timeout: requestTimeout, + responseType: "text", + transformResponse: [(data) => data], + validateStatus: (status) => status >= 200 && status < 300, + }); + + return typeof response.data === "string" ? response.data : ""; + } catch (error) { + logDebug("Global documentation request failed", error); + throw error; + } + } +} diff --git a/src/ccs/sourcecontrol/clients/resolveDefinitionClient.ts b/src/ccs/sourcecontrol/clients/resolveDefinitionClient.ts new file mode 100644 index 00000000..b2875f63 --- /dev/null +++ b/src/ccs/sourcecontrol/clients/resolveDefinitionClient.ts @@ -0,0 +1,130 @@ +import axios from "axios"; +import * as vscode from "vscode"; + +import { AtelierAPI } from "../../../api"; +import { getCcsSettings } from "../../config/settings"; +import { createAbortSignal } from "../../core/http"; +import { logDebug } from "../../core/logging"; +import { ResolveDefinitionResponse } from "../../core/types"; +import { SourceControlApi } from "../client"; +import { ROUTES } from "../routes"; +import { toVscodeLocation } from "../paths"; + +export class ResolveDefinitionClient { + private readonly apiFactory: (api: AtelierAPI) => SourceControlApi; + + public constructor(apiFactory: (api: AtelierAPI) => SourceControlApi = SourceControlApi.fromAtelierApi) { + this.apiFactory = apiFactory; + } + + private getAdditionalNamespaces(currentApi: AtelierAPI): string[] { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders?.length) { + return []; + } + + const { host, port } = currentApi.config; + const currentPathPrefix = currentApi.config.pathPrefix ?? ""; + const currentNamespace = currentApi.ns; + + if (!host || !port) { + return []; + } + + const namespaces = new Set(); + + for (const folder of workspaceFolders) { + const folderApi = new AtelierAPI(folder.uri); + if (!folderApi.active) { + continue; + } + + const { host: folderHost, port: folderPort } = folderApi.config; + const folderPathPrefix = folderApi.config.pathPrefix ?? ""; + + if (folderHost !== host || folderPort !== port || folderPathPrefix !== currentPathPrefix) { + continue; + } + + const folderNamespace = folderApi.ns; + if (!folderNamespace || folderNamespace === currentNamespace) { + continue; + } + + namespaces.add(folderNamespace.toUpperCase()); + } + + return Array.from(namespaces); + } + + public async resolve( + document: vscode.TextDocument, + query: string, + token: vscode.CancellationToken + ): Promise { + const api = new AtelierAPI(document.uri); + const { host, port, username, password } = api.config; + const namespace = api.ns; + + if (!api.active || !namespace || !host || !port || !username || !password) { + logDebug("CCS definition lookup skipped due to missing connection metadata", { + active: api.active, + namespace, + host, + port, + username: Boolean(username), + password: Boolean(password), + }); + return undefined; + } + + let sourceControlApi: SourceControlApi; + try { + sourceControlApi = this.apiFactory(api); + } catch (error) { + logDebug("Failed to create SourceControl API client", error); + return undefined; + } + + const { requestTimeout } = getCcsSettings(); + const { signal, dispose } = createAbortSignal(token); + + const otherNamespaces = this.getAdditionalNamespaces(api); + const otherNamespacesStr = otherNamespaces.join(";"); + const body = otherNamespaces.length ? { query, otherNamespaces: otherNamespacesStr } : { query }; + + logDebug("CCS definition lookup request", { + namespace, + endpoint: ROUTES.resolveDefinition(namespace), + body, + }); + + try { + const response = await sourceControlApi.post( + ROUTES.resolveDefinition(namespace), + body, + { + timeout: requestTimeout, + signal, + validateStatus: (status) => status >= 200 && status < 300, + } + ); + + const location = toVscodeLocation(response.data ?? {}); + if (!location) { + logDebug("CCS definition lookup returned empty payload", response.data); + } + return location ?? undefined; + } catch (error) { + if (axios.isCancel(error)) { + logDebug("CCS definition lookup cancelled"); + return undefined; + } + + logDebug("CCS definition lookup failed", error); + return undefined; + } finally { + dispose(); + } + } +} diff --git a/src/ccs/sourcecontrol/paths.ts b/src/ccs/sourcecontrol/paths.ts new file mode 100644 index 00000000..c51ddcc8 --- /dev/null +++ b/src/ccs/sourcecontrol/paths.ts @@ -0,0 +1,36 @@ +import * as vscode from "vscode"; + +import { LocationJSON } from "../core/types"; + +export function normalizeFilePath(filePath: string): string { + if (!filePath) { + return filePath; + } + + const trimmed = filePath.trim(); + if (/^file:\/\//i.test(trimmed)) { + return trimmed.replace(/\\/g, "/"); + } + + const normalized = trimmed.replace(/\\/g, "/"); + return normalized; +} + +export function toFileUri(filePath: string): vscode.Uri { + const normalized = normalizeFilePath(filePath); + if (/^file:\/\//i.test(normalized)) { + return vscode.Uri.parse(normalized); + } + + return vscode.Uri.file(normalized); +} + +export function toVscodeLocation(location: LocationJSON): vscode.Location | undefined { + if (!location.uri || typeof location.line !== "number") { + return undefined; + } + + const uri = toFileUri(location.uri); + const zeroBasedLine = Math.max(0, Math.floor(location.line) - 1); + return new vscode.Location(uri, new vscode.Position(zeroBasedLine, 0)); +} diff --git a/src/ccs/sourcecontrol/routes.ts b/src/ccs/sourcecontrol/routes.ts new file mode 100644 index 00000000..1561570f --- /dev/null +++ b/src/ccs/sourcecontrol/routes.ts @@ -0,0 +1,9 @@ +export const BASE_PATH = "/api/sourcecontrol/vscode" as const; + +export const ROUTES = { + resolveContextExpression: () => `/resolveContextExpression`, + getGlobalDocumentation: () => `/getGlobalDocumentation`, + resolveDefinition: (namespace: string) => `/namespaces/${encodeURIComponent(namespace)}/resolveDefinition`, +} as const; + +export type RouteKey = keyof typeof ROUTES; diff --git a/src/commands/globalDocumentation.ts b/src/commands/globalDocumentation.ts new file mode 100644 index 00000000..7ecd2c6a --- /dev/null +++ b/src/commands/globalDocumentation.ts @@ -0,0 +1,60 @@ +import * as vscode from "vscode"; + +import { AtelierAPI } from "../api"; +import { currentFile, handleError, outputChannel } from "../utils"; + +function getSelectedOrCurrentLineText(editor: vscode.TextEditor): string { + const { selection, document } = editor; + if (!selection || selection.isEmpty) { + return document.lineAt(selection.active.line).text.trim(); + } + return document.getText(selection).trim(); +} + +export async function showGlobalDocumentation(): Promise { + const file = currentFile(); + const editor = vscode.window.activeTextEditor; + + if (!file || !editor) { + return; + } + + const selectedText = getSelectedOrCurrentLineText(editor); + + if (!selectedText) { + void vscode.window.showErrorMessage("Selection is empty. Select text or place the cursor on a line with content."); + return; + } + + const api = new AtelierAPI(file.uri); + + if (!api.active) { + void vscode.window.showErrorMessage("No active connection to retrieve global documentation."); + return; + } + + try { + const response = await api.getGlobalDocumentation({ selectedText }); + const content = response?.result?.content; + let output = ""; + + if (Array.isArray(content)) { + output = content.join("\n"); + } else if (typeof content === "string") { + output = content; + } else if (content && typeof content === "object") { + output = JSON.stringify(content, null, 2); + } + + if (!output) { + void vscode.window.showInformationMessage("Global documentation did not return any content."); + return; + } + + outputChannel.appendLine("==================== Global Documentation ===================="); + outputChannel.appendLine(output); + outputChannel.show(true); + } catch (error) { + handleError(error, "Failed to retrieve global documentation."); + } +} diff --git a/src/extension.ts b/src/extension.ts index 45c1a4e0..6f6c9133 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -27,6 +27,10 @@ export const incLangId = "objectscript-macros"; export const cspLangId = "objectscript-csp"; export const outputLangId = "vscode-objectscript-output"; +const dotPrefixRegex = /^(\s*(?:\.\s*)+)/; +const dotIndentLanguages = new Set([macLangId, intLangId]); +const dotIndentSkipDocuments = new Set(); + import * as url from "url"; import path = require("path"); import { @@ -159,6 +163,14 @@ import { WorkspaceNode, NodeBase } from "./explorer/nodes"; import { showPlanWebview } from "./commands/showPlanPanel"; import { isfsConfig } from "./utils/FileProviderUtil"; import { showAllClassMembers } from "./commands/showAllClassMembers"; +import { + PrioritizedDefinitionProvider, + DefinitionDocumentLinkProvider, + followDefinitionLinkCommand, + goToDefinitionLocalFirst, + resolveContextExpression, + showGlobalDocumentation, +} from "./ccs"; const packageJson = vscode.extensions.getExtension(extensionId).packageJSON; const extensionVersion = packageJson.version; @@ -940,6 +952,20 @@ export async function activate(context: vscode.ExtensionContext): Promise { const documentSelector = (...list) => ["file", ...schemas].reduce((acc, scheme) => acc.concat(list.map((language) => ({ scheme, language }))), []); + const definitionDocumentLinkProvider = new DefinitionDocumentLinkProvider([ + clsLangId, + macLangId, + intLangId, + incLangId, + ]); + context.subscriptions.push( + definitionDocumentLinkProvider, + vscode.languages.registerDocumentLinkProvider( + documentSelector(clsLangId, macLangId, intLangId, incLangId), + definitionDocumentLinkProvider + ) + ); + const diagnosticProvider = new ObjectScriptDiagnosticProvider(); // Gather the proposed APIs we will register to use when building with enabledApiProposals != [] @@ -1001,7 +1027,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { ), vscode.languages.registerDefinitionProvider( documentSelector(clsLangId, macLangId, intLangId, incLangId), - new ObjectScriptDefinitionProvider() + new PrioritizedDefinitionProvider(new ObjectScriptDefinitionProvider()) ), vscode.languages.registerCompletionItemProvider( documentSelector(clsLangId, macLangId, intLangId, incLangId), @@ -1038,6 +1064,75 @@ export async function activate(context: vscode.ExtensionContext): Promise { } } + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument(async (event) => { + if (!dotIndentLanguages.has(event.document.languageId)) { + return; + } + + const docUriString = event.document.uri.toString(); + if (dotIndentSkipDocuments.has(docUriString)) { + return; + } + + const editor = vscode.window.visibleTextEditors.find((e) => e.document === event.document); + if (!editor) { + return; + } + + for (const change of event.contentChanges) { + if (!change.text.includes("\n")) { + continue; + } + + const newLineNumber = change.range.start.line + 1; + if (newLineNumber >= event.document.lineCount || newLineNumber <= 0) { + continue; + } + + const previousLine = event.document.lineAt(newLineNumber - 1).text; + const prefixMatch = previousLine.match(dotPrefixRegex); + if (!prefixMatch) { + continue; + } + + let insertText = prefixMatch[1]; + if (!insertText.endsWith(" ")) { + insertText += " "; + } + + const remainder = previousLine.slice(prefixMatch[1].length); + if (remainder.startsWith(";")) { + insertText += ";"; + } + + const newLine = event.document.lineAt(newLineNumber); + if (newLine.text.trim().length > 0) { + continue; + } + if (newLine.text.startsWith(insertText)) { + continue; + } + + const indentMatch = newLine.text.match(/^\s*/); + const indentLength = indentMatch ? indentMatch[0].length : 0; + const replaceRange = new vscode.Range(newLine.range.start, new vscode.Position(newLineNumber, indentLength)); + + dotIndentSkipDocuments.add(docUriString); + try { + await editor.edit( + (editBuilder) => { + editBuilder.replace(replaceRange, insertText); + }, + { undoStopBefore: false, undoStopAfter: false } + ); + } finally { + dotIndentSkipDocuments.delete(docUriString); + } + } + }) + ); + openedClasses = workspaceState.get("openedClasses") ?? []; /** The stringified URIs of all `isfs` documents that are currently open in a UI tab */ @@ -1191,6 +1286,36 @@ export async function activate(context: vscode.ExtensionContext): Promise { sendCommandTelemetryEvent("copyToClipboard"); vscode.env.clipboard.writeText(command); }), + vscode.commands.registerCommand("vscode-objectscript.resolveContextExpression", () => { + sendCommandTelemetryEvent("resolveContextExpression"); + void resolveContextExpression(); + }), + vscode.commands.registerCommand("vscode-objectscript.ccs.goToDefinition", async () => { + sendCommandTelemetryEvent("ccs.goToDefinition"); + await goToDefinitionLocalFirst(); + }), + vscode.commands.registerCommand( + followDefinitionLinkCommand, + async (documentUri: string, line: number, character: number) => { + sendCommandTelemetryEvent("ccs.followDefinitionLink"); + if (!documentUri || typeof line !== "number" || typeof character !== "number") { + return; + } + + const uri = vscode.Uri.parse(documentUri); + const document = + vscode.workspace.textDocuments.find((doc) => doc.uri.toString() === documentUri) ?? + (await vscode.workspace.openTextDocument(uri)); + + const position = new vscode.Position(line, character); + const selectionRange = new vscode.Range(position, position); + const editor = await vscode.window.showTextDocument(document, { selection: selectionRange }); + editor.selection = new vscode.Selection(position, position); + editor.revealRange(selectionRange); + + await goToDefinitionLocalFirst(); + } + ), vscode.commands.registerCommand("vscode-objectscript.debug", (program: string, askArgs: boolean) => { sendCommandTelemetryEvent("debug"); const startDebugging = (args) => { @@ -1299,6 +1424,10 @@ export async function activate(context: vscode.ExtensionContext): Promise { sendCommandTelemetryEvent("viewOthers"); viewOthers(false); }), + vscode.commands.registerCommand("vscode-objectscript.getGlobalDocumentation", () => { + sendCommandTelemetryEvent("getGlobalDocumentation"); + void showGlobalDocumentation(); + }), vscode.commands.registerCommand("vscode-objectscript.serverCommands.sourceControl", (uri?: vscode.Uri) => { sendCommandTelemetryEvent("serverCommands.sourceControl"); mainSourceControlMenu(uri); @@ -2098,3 +2227,4 @@ export async function deactivate(): Promise { } await Promise.allSettled(promises); } +export { outputChannel }; diff --git a/src/languageConfiguration.ts b/src/languageConfiguration.ts index 93b9513f..06734f1b 100644 --- a/src/languageConfiguration.ts +++ b/src/languageConfiguration.ts @@ -2,6 +2,21 @@ import * as vscode from "vscode"; export function getLanguageConfiguration(lang: string): vscode.LanguageConfiguration { const conf = vscode.workspace.getConfiguration("objectscript"); + const onEnterRules: vscode.OnEnterRule[] = []; + + if (lang === "objectscript-class") { + onEnterRules.push({ + beforeText: /^\/\/\//, + action: { indentAction: vscode.IndentAction.None, appendText: "/// " }, + }); + } + + if (["objectscript", "objectscript-int"].includes(lang)) { + onEnterRules.push({ + beforeText: /^\s*;/, + action: { indentAction: vscode.IndentAction.None, appendText: ";" }, + }); + } return { wordPattern: /((?<=(class|extends|as|of) )(%?\b[a-z0-9]+(\.[a-z0-9]+)*\b))|(\^[a-z0-9]+(\.[a-z0-9]+)*)|((\${1,3}|[irm]?%|\^|#)?[a-z0-9]+)/i, @@ -40,14 +55,6 @@ export function getLanguageConfiguration(lang: string): vscode.LanguageConfigura notIn: [vscode.SyntaxTokenType.Comment, vscode.SyntaxTokenType.String, vscode.SyntaxTokenType.RegEx], }, ], - onEnterRules: - lang == "objectscript-class" - ? [ - { - beforeText: /^\/\/\//, - action: { indentAction: vscode.IndentAction.None, appendText: "/// " }, - }, - ] - : undefined, + onEnterRules: onEnterRules.length ? onEnterRules : undefined, }; } diff --git a/src/test/suite/extension.test.ts b/src/test/suite/extension.test.ts index e162ec0b..99be046b 100644 --- a/src/test/suite/extension.test.ts +++ b/src/test/suite/extension.test.ts @@ -3,9 +3,21 @@ import { before } from "mocha"; // You can import and use all API from the 'vscode' module // as well as import your extension to test it +import * as vscode from "vscode"; import { window, extensions } from "vscode"; import { extensionId, smExtensionId } from "../../extension"; +async function waitForCondition(predicate: () => boolean, timeoutMs = 1000, message?: string): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.fail(message ?? "Timed out waiting for condition"); +} + suite("Extension Test Suite", () => { suiteSetup(async function () { // make sure extension is activated @@ -22,4 +34,57 @@ suite("Extension Test Suite", () => { test("Sample test", () => { assert.ok("All good"); }); + + test("Dot-prefixed statements continue on newline", async () => { + const document = await vscode.workspace.openTextDocument({ + language: "objectscript", + content: " . Do ##class(Test).Run()", + }); + const editor = await vscode.window.showTextDocument(document); + try { + await editor.edit((editBuilder) => { + editBuilder.insert(document.lineAt(0).range.end, "\n"); + }); + await waitForCondition(() => document.lineCount > 1); + await waitForCondition(() => document.lineAt(1).text.length > 0); + assert.strictEqual(document.lineAt(1).text, " . "); + } finally { + await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + } + }); + + test("Dot-prefixed semicolon comments continue on newline", async () => { + const document = await vscode.workspace.openTextDocument({ + language: "objectscript", + content: " . ; Comment", + }); + const editor = await vscode.window.showTextDocument(document); + try { + await editor.edit((editBuilder) => { + editBuilder.insert(document.lineAt(0).range.end, "\n"); + }); + await waitForCondition(() => document.lineCount > 1); + await waitForCondition(() => document.lineAt(1).text.length > 0); + assert.strictEqual(document.lineAt(1).text, " . ;"); + } finally { + await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + } + }); + + test("Moving lines across dot-prefixed semicolon comments doesn't add semicolons", async () => { + const document = await vscode.workspace.openTextDocument({ + language: "objectscript", + content: " . Do ##class(Test).Run()\n . ; Comment", + }); + const editor = await vscode.window.showTextDocument(document); + try { + editor.selection = new vscode.Selection(new vscode.Position(0, 0), new vscode.Position(0, 0)); + await vscode.commands.executeCommand("editor.action.moveLinesDownAction"); + const expectedText = " . ; Comment\n . Do ##class(Test).Run()"; + await waitForCondition(() => document.getText() === expectedText); + assert.strictEqual(document.getText(), expectedText); + } finally { + await vscode.commands.executeCommand("workbench.action.closeActiveEditor"); + } + }); });