@@ -74,6 +74,7 @@ export const registerCodeActions = (context: vscode.ExtensionContext) => {
7474 registerOptionalConfigFixes ( context ) ;
7575 registerMissingConfigFixes ( context ) ;
7676 registerUnknownConfigPropertyFixes ( context ) ;
77+ registerInvalidConfigSectionFixes ( context ) ;
7778} ;
7879
7980function registerInvalidSchemaFixes (
@@ -741,3 +742,217 @@ function calculatePropertyDeleteRange(
741742
742743 return propertyRange ;
743744}
745+
746+ /**
747+ * Registers code actions for invalid config sections.
748+ * Provides "Remove section" and "Link to plugin" quick fixes.
749+ */
750+ function registerInvalidConfigSectionFixes ( context : vscode . ExtensionContext ) : void {
751+ // Register the command for linking a config section to a plugin.
752+ // Use try-catch to handle cases where the command is already registered
753+ // (e.g., during test runs that call registerCodeActions multiple times).
754+ try {
755+ context . subscriptions . push (
756+ vscode . commands . registerCommand (
757+ 'dev-proxy-toolkit.linkConfigSectionToPlugin' ,
758+ async ( documentUri : vscode . Uri , configSectionName : string ) => {
759+ const document = await vscode . workspace . openTextDocument ( documentUri ) ;
760+
761+ let documentNode : parse . ObjectNode ;
762+ try {
763+ documentNode = parse ( document . getText ( ) ) as parse . ObjectNode ;
764+ } catch {
765+ return ;
766+ }
767+
768+ const pluginsNode = getASTNode ( documentNode . children , 'Identifier' , 'plugins' ) ;
769+ if ( ! pluginsNode || pluginsNode . value . type !== 'Array' ) {
770+ return ;
771+ }
772+
773+ const pluginNodes = ( pluginsNode . value as parse . ArrayNode )
774+ . children as parse . ObjectNode [ ] ;
775+
776+ // Find plugins that don't have a configSection property
777+ const availablePlugins : { name : string ; index : number ; node : parse . ObjectNode } [ ] = [ ] ;
778+ pluginNodes . forEach ( ( pluginNode , index ) => {
779+ const nameNode = getASTNode ( pluginNode . children , 'Identifier' , 'name' ) ;
780+ const configSectionNode = getASTNode ( pluginNode . children , 'Identifier' , 'configSection' ) ;
781+ if ( nameNode && ! configSectionNode ) {
782+ availablePlugins . push ( {
783+ name : ( nameNode . value as parse . LiteralNode ) . value as string ,
784+ index,
785+ node : pluginNode ,
786+ } ) ;
787+ }
788+ } ) ;
789+
790+ if ( availablePlugins . length === 0 ) {
791+ vscode . window . showInformationMessage ( 'All plugins already have a configSection.' ) ;
792+ return ;
793+ }
794+
795+ // Check for duplicate plugin names to disambiguate in the picker
796+ const nameCounts = new Map < string , number > ( ) ;
797+ availablePlugins . forEach ( p => {
798+ nameCounts . set ( p . name , ( nameCounts . get ( p . name ) ?? 0 ) + 1 ) ;
799+ } ) ;
800+
801+ const quickPickItems = availablePlugins . map ( p => ( {
802+ label : nameCounts . get ( p . name ) ! > 1
803+ ? `${ p . name } (plugin #${ p . index + 1 } )`
804+ : p . name ,
805+ plugin : p ,
806+ } ) ) ;
807+
808+ const selected = await vscode . window . showQuickPick (
809+ quickPickItems ,
810+ { placeHolder : 'Select a plugin to link this config section to' }
811+ ) ;
812+
813+ if ( ! selected ) {
814+ return ;
815+ }
816+
817+ const selectedPlugin = selected . plugin ;
818+ if ( selectedPlugin . node . children . length === 0 ) {
819+ return ;
820+ }
821+
822+ const edit = new vscode . WorkspaceEdit ( ) ;
823+ const lastProperty = selectedPlugin . node . children [ selectedPlugin . node . children . length - 1 ] ;
824+ const insertPos = new vscode . Position (
825+ lastProperty . loc ! . end . line - 1 ,
826+ lastProperty . loc ! . end . column
827+ ) ;
828+
829+ edit . insert (
830+ documentUri ,
831+ insertPos ,
832+ `,\n "configSection": "${ configSectionName } "`
833+ ) ;
834+
835+ await vscode . workspace . applyEdit ( edit ) ;
836+ await vscode . commands . executeCommand ( 'editor.action.formatDocument' ) ;
837+ }
838+ )
839+ ) ;
840+ } catch {
841+ // Command already registered, skip
842+ }
843+
844+ const invalidConfigSection : vscode . CodeActionProvider = {
845+ provideCodeActions : ( document , range , context ) => {
846+ const currentDiagnostic = findDiagnosticByCode (
847+ context . diagnostics ,
848+ 'invalidConfigSection' ,
849+ range
850+ ) ;
851+
852+ if ( ! currentDiagnostic ) {
853+ return [ ] ;
854+ }
855+
856+ // Extract config section name from diagnostic message
857+ const match = currentDiagnostic . message . match ( / ^ C o n f i g s e c t i o n ' ( \w + ) ' / ) ;
858+ if ( ! match ) {
859+ return [ ] ;
860+ }
861+
862+ const configSectionName = match [ 1 ] ;
863+ const fixes : vscode . CodeAction [ ] = [ ] ;
864+
865+ // 1. "Remove section" fix
866+ try {
867+ const documentNode = parse ( document . getText ( ) ) as parse . ObjectNode ;
868+ const configSectionProperty = getASTNode (
869+ documentNode . children ,
870+ 'Identifier' ,
871+ configSectionName
872+ ) ;
873+
874+ if ( configSectionProperty ) {
875+ const removeFix = new vscode . CodeAction (
876+ `Remove '${ configSectionName } ' section` ,
877+ vscode . CodeActionKind . QuickFix
878+ ) ;
879+
880+ removeFix . edit = new vscode . WorkspaceEdit ( ) ;
881+
882+ const deleteRange = calculateConfigSectionDeleteRange (
883+ document ,
884+ configSectionProperty
885+ ) ;
886+ removeFix . edit . delete ( document . uri , deleteRange ) ;
887+
888+ removeFix . command = {
889+ command : 'editor.action.formatDocument' ,
890+ title : 'Format Document' ,
891+ } ;
892+
893+ removeFix . isPreferred = true ;
894+ fixes . push ( removeFix ) ;
895+ }
896+ } catch {
897+ // If AST parsing fails, skip the remove fix
898+ }
899+
900+ // 2. "Link to plugin" fix
901+ const linkFix = new vscode . CodeAction (
902+ `Link '${ configSectionName } ' to a plugin...` ,
903+ vscode . CodeActionKind . QuickFix
904+ ) ;
905+ linkFix . command = {
906+ command : 'dev-proxy-toolkit.linkConfigSectionToPlugin' ,
907+ title : 'Link config section to plugin' ,
908+ arguments : [ document . uri , configSectionName ] ,
909+ } ;
910+ fixes . push ( linkFix ) ;
911+
912+ return fixes ;
913+ } ,
914+ } ;
915+
916+ registerJsonCodeActionProvider ( context , invalidConfigSection ) ;
917+ }
918+
919+ /**
920+ * Calculate the range to delete for a config section property, including comma handling.
921+ */
922+ function calculateConfigSectionDeleteRange (
923+ document : vscode . TextDocument ,
924+ propertyNode : parse . PropertyNode ,
925+ ) : vscode . Range {
926+ const propRange = getRangeFromASTNode ( propertyNode ) ;
927+
928+ // Check if there's a comma after the property on the end line
929+ const endLineText = document . lineAt ( propRange . end . line ) . text ;
930+ const afterProp = endLineText . substring ( propRange . end . character ) ;
931+ const commaAfterMatch = afterProp . match ( / ^ \s * , / ) ;
932+
933+ if ( commaAfterMatch ) {
934+ // Delete from start of line to end of line (including comma)
935+ return new vscode . Range (
936+ new vscode . Position ( propRange . start . line , 0 ) ,
937+ new vscode . Position ( propRange . end . line + 1 , 0 )
938+ ) ;
939+ }
940+
941+ // No comma after - remove preceding comma if exists
942+ if ( propRange . start . line > 0 ) {
943+ const prevLineText = document . lineAt ( propRange . start . line - 1 ) . text ;
944+ if ( prevLineText . trimEnd ( ) . endsWith ( ',' ) ) {
945+ const commaPos = prevLineText . lastIndexOf ( ',' ) ;
946+ return new vscode . Range (
947+ new vscode . Position ( propRange . start . line - 1 , commaPos ) ,
948+ new vscode . Position ( propRange . end . line + 1 , 0 )
949+ ) ;
950+ }
951+ }
952+
953+ // Fallback: delete just the property lines
954+ return new vscode . Range (
955+ new vscode . Position ( propRange . start . line , 0 ) ,
956+ new vscode . Position ( propRange . end . line + 1 , 0 )
957+ ) ;
958+ }
0 commit comments