mbeditor 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +27 -0
- data/app/assets/javascripts/mbeditor/application.js +1 -0
- data/app/assets/javascripts/mbeditor/components/EditorPanel.js +144 -55
- data/app/assets/javascripts/mbeditor/editor_plugins.js +6 -0
- data/app/assets/javascripts/mbeditor/ruby_outline.js +427 -0
- data/app/assets/stylesheets/mbeditor/editor.css +48 -1
- data/app/controllers/mbeditor/application_controller.rb +5 -11
- data/app/controllers/mbeditor/editors_controller.rb +1 -1
- data/app/services/mbeditor/exclusion_matcher.rb +105 -3
- data/app/services/mbeditor/file_tree_service.rb +1 -1
- data/app/services/mbeditor/git_service.rb +3 -10
- data/app/services/mbeditor/ruby_definition_service.rb +1 -1
- data/app/services/mbeditor/safe_path.rb +57 -0
- data/app/services/mbeditor/search_replace_service.rb +2 -2
- data/lib/mbeditor/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2612297fd4760bf7660ab89589c549ff6a815df395e4f57fb48d6f60c1c519b8
|
|
4
|
+
data.tar.gz: d9bf030f5f056754bfe8c633253aba3013c7405aaba259628c0e7aa5e7b19aff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8660eda73b4cb4e3d38ab7c820286a363340e6a65592e66817c5b0594374e60532769c06999fb9a099b6082ee99f4fd1f604ec6116908f63317daed1153780d7
|
|
7
|
+
data.tar.gz: b27df09cb524a48508285abe5892b8f261f44efaa471fe580a839be1e3f2064d385848aa4e2b488af812d88b3181ee9f51e26c3e3cb84bc79353c619184ddd80
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.9.0] - 2026-07-23
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Test-aware Ruby Outline** — the editor now recognises Minitest and RSpec
|
|
12
|
+
suites, tests, and helper methods in `_test.rb` and `_spec.rb` files. It
|
|
13
|
+
reads the unsaved Monaco buffer, preserves nested suite depth and method
|
|
14
|
+
visibility, supports keyboard navigation, and safely reports truncation or
|
|
15
|
+
parser failures without disrupting the editor.
|
|
16
|
+
- **Ruby test-DSL highlighting** — Minitest/RSpec declarations, hooks, and
|
|
17
|
+
helpers now receive structural Monaco tokens while Ruby files retain the
|
|
18
|
+
existing `ruby` language identity and integrations.
|
|
19
|
+
|
|
20
|
+
### Security
|
|
21
|
+
- **Dangling symlink containment** — every editor and Git path check now
|
|
22
|
+
rejects dangling symlinks that resolve outside the workspace or repository,
|
|
23
|
+
closing a write-through escape for create, save, rename, and directory
|
|
24
|
+
operations.
|
|
25
|
+
- **Filesystem-aware exclusions** — exclusion matching now normalizes Unicode
|
|
26
|
+
spellings and folds case only on case-insensitive filesystems, preventing
|
|
27
|
+
case or normalization variants from reaching excluded directories such as
|
|
28
|
+
`.git`.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- Generated `graphify-out/` artifacts are ignored by Git.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
8
35
|
## [0.8.1] - 2026-07-22
|
|
9
36
|
|
|
10
37
|
### Fixed
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
//= require mbeditor/tab_manager
|
|
12
12
|
//= require mbeditor/color_provider
|
|
13
13
|
//= require mbeditor/editor_plugins
|
|
14
|
+
//= require mbeditor/ruby_outline
|
|
14
15
|
//= require mbeditor/components/CollapsibleSection
|
|
15
16
|
//= require mbeditor/components/ShortcutHelp
|
|
16
17
|
//= require mbeditor/components/DiffViewer
|
|
@@ -88,33 +88,44 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
88
88
|
var methodsList = _useState14[0];
|
|
89
89
|
var setMethodsList = _useState14[1];
|
|
90
90
|
|
|
91
|
-
var _useState15 = useState(
|
|
91
|
+
var _useState15 = useState(false);
|
|
92
92
|
var _useState16 = _slicedToArray(_useState15, 2);
|
|
93
|
-
var
|
|
94
|
-
var
|
|
93
|
+
var methodsTruncated = _useState16[0];
|
|
94
|
+
var setMethodsTruncated = _useState16[1];
|
|
95
95
|
|
|
96
|
-
var
|
|
97
|
-
|
|
98
|
-
// Local pagination state — initialized from tab props; updated on page navigation
|
|
99
|
-
var _useState17 = useState(tab.startLine || 0);
|
|
96
|
+
var _useState17 = useState(false);
|
|
100
97
|
var _useState18 = _slicedToArray(_useState17, 2);
|
|
101
|
-
var
|
|
102
|
-
var
|
|
98
|
+
var methodsUnavailable = _useState18[0];
|
|
99
|
+
var setMethodsUnavailable = _useState18[1];
|
|
103
100
|
|
|
104
|
-
var _useState19 = useState(
|
|
101
|
+
var _useState19 = useState(null);
|
|
105
102
|
var _useState20 = _slicedToArray(_useState19, 2);
|
|
106
|
-
var
|
|
107
|
-
var
|
|
103
|
+
var methodsDropdownPos = _useState20[0];
|
|
104
|
+
var setMethodsDropdownPos = _useState20[1];
|
|
105
|
+
|
|
106
|
+
var methodsBtnRef = useRef(null);
|
|
107
|
+
var methodsDropdownRef = useRef(null);
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
// Local pagination state — initialized from tab props; updated on page navigation
|
|
110
|
+
var _useState21 = useState(tab.startLine || 0);
|
|
110
111
|
var _useState22 = _slicedToArray(_useState21, 2);
|
|
111
|
-
var
|
|
112
|
-
var
|
|
112
|
+
var pageStartLine = _useState22[0];
|
|
113
|
+
var setPageStartLine = _useState22[1];
|
|
113
114
|
|
|
114
|
-
var _useState23 = useState(tab.
|
|
115
|
+
var _useState23 = useState(tab.lineCount || 0);
|
|
115
116
|
var _useState24 = _slicedToArray(_useState23, 2);
|
|
116
|
-
var
|
|
117
|
-
var
|
|
117
|
+
var pageLineCount = _useState24[0];
|
|
118
|
+
var setPageLineCount = _useState24[1];
|
|
119
|
+
|
|
120
|
+
var _useState25 = useState(tab.totalLines || 0);
|
|
121
|
+
var _useState26 = _slicedToArray(_useState25, 2);
|
|
122
|
+
var pageTotalLines = _useState26[0];
|
|
123
|
+
var setPageTotalLines = _useState26[1];
|
|
124
|
+
|
|
125
|
+
var _useState27 = useState(tab.totalBytes || 0);
|
|
126
|
+
var _useState28 = _slicedToArray(_useState27, 2);
|
|
127
|
+
var pageTotalBytes = _useState28[0];
|
|
128
|
+
var setPageTotalBytes = _useState28[1];
|
|
118
129
|
|
|
119
130
|
var onFormatRef = useRef(onFormat);
|
|
120
131
|
onFormatRef.current = onFormat;
|
|
@@ -1601,6 +1612,13 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
1601
1612
|
var fileBaseName = (tab.path || '').split('/').pop().toLowerCase();
|
|
1602
1613
|
var isRubyFile = ext === 'rb' || ext === 'ruby' || ext === 'gemspec' || ext === 'rake' ||
|
|
1603
1614
|
fileBaseName === 'gemfile' || fileBaseName === 'gemfile.lock' || fileBaseName === 'rakefile';
|
|
1615
|
+
var isTestOutline = false;
|
|
1616
|
+
try {
|
|
1617
|
+
isTestOutline = isRubyFile && window.RubyOutline &&
|
|
1618
|
+
typeof window.RubyOutline.isTestPath === 'function' && window.RubyOutline.isTestPath(tab.path);
|
|
1619
|
+
} catch (err) {
|
|
1620
|
+
isTestOutline = false;
|
|
1621
|
+
}
|
|
1604
1622
|
|
|
1605
1623
|
useEffect(function () {
|
|
1606
1624
|
if (isMarkdown && window.marked) {
|
|
@@ -1638,8 +1656,8 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
1638
1656
|
if (!methodsOpen) return;
|
|
1639
1657
|
function handleClickOutside(e) {
|
|
1640
1658
|
var btn = methodsBtnRef.current;
|
|
1641
|
-
|
|
1642
|
-
if (btn && !btn.contains(e.target)) {
|
|
1659
|
+
var dropdown = methodsDropdownRef.current;
|
|
1660
|
+
if (btn && !btn.contains(e.target) && (!dropdown || !dropdown.contains(e.target))) {
|
|
1643
1661
|
setMethodsOpen(false);
|
|
1644
1662
|
}
|
|
1645
1663
|
}
|
|
@@ -1667,20 +1685,14 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
1667
1685
|
});
|
|
1668
1686
|
}
|
|
1669
1687
|
|
|
1670
|
-
// Parse
|
|
1671
|
-
function
|
|
1672
|
-
var methods = [];
|
|
1688
|
+
// Parse the current Monaco buffer so unsaved Ruby test declarations appear too.
|
|
1689
|
+
function parseRubyOutline(model, path) {
|
|
1673
1690
|
var lineCount = model.getLineCount();
|
|
1674
|
-
var
|
|
1691
|
+
var lines = [];
|
|
1675
1692
|
for (var i = 1; i <= lineCount; i++) {
|
|
1676
|
-
|
|
1677
|
-
var m = DEF_RE.exec(line);
|
|
1678
|
-
if (m) {
|
|
1679
|
-
var selfPrefix = m[1] ? 'self.' : '';
|
|
1680
|
-
methods.push({ line: i, name: selfPrefix + m[2] });
|
|
1681
|
-
}
|
|
1693
|
+
lines.push(model.getLineContent(i));
|
|
1682
1694
|
}
|
|
1683
|
-
return
|
|
1695
|
+
return window.RubyOutline.parse(lines, { path: path });
|
|
1684
1696
|
}
|
|
1685
1697
|
|
|
1686
1698
|
if (tab.fileNotFound) {
|
|
@@ -1831,7 +1843,16 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
1831
1843
|
var nextOpen = !methodsOpen;
|
|
1832
1844
|
if (nextOpen) {
|
|
1833
1845
|
var model = monacoRef.current && monacoRef.current.getModel();
|
|
1834
|
-
|
|
1846
|
+
try {
|
|
1847
|
+
var result = model ? parseRubyOutline(model, tab.path) : { entries: [], truncated: false };
|
|
1848
|
+
setMethodsList(result.entries || []);
|
|
1849
|
+
setMethodsTruncated(!!result.truncated);
|
|
1850
|
+
setMethodsUnavailable(false);
|
|
1851
|
+
} catch (err) {
|
|
1852
|
+
setMethodsList([]);
|
|
1853
|
+
setMethodsTruncated(false);
|
|
1854
|
+
setMethodsUnavailable(true);
|
|
1855
|
+
}
|
|
1835
1856
|
if (methodsBtnRef.current) {
|
|
1836
1857
|
var rect = methodsBtnRef.current.getBoundingClientRect();
|
|
1837
1858
|
setMethodsDropdownPos({ top: rect.bottom + 4, right: window.innerWidth - rect.right });
|
|
@@ -1839,10 +1860,10 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
1839
1860
|
}
|
|
1840
1861
|
setMethodsOpen(nextOpen);
|
|
1841
1862
|
},
|
|
1842
|
-
title: 'Jump to Method'
|
|
1863
|
+
title: isTestOutline ? 'Jump to Outline' : 'Jump to Method'
|
|
1843
1864
|
},
|
|
1844
1865
|
React.createElement('i', { className: 'fas fa-list-ul', style: { marginRight: editorPrefs.toolbarIconOnly ? 0 : '5px', flexShrink: 0 } }),
|
|
1845
|
-
!editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, 'Methods')
|
|
1866
|
+
!editorPrefs.toolbarIconOnly && React.createElement('span', { className: 'ide-toolbar-label' }, isTestOutline ? 'Outline' : 'Methods')
|
|
1846
1867
|
),
|
|
1847
1868
|
gitAvailable && React.createElement(
|
|
1848
1869
|
'button',
|
|
@@ -2029,31 +2050,99 @@ var EditorPanel = function EditorPanel(_ref) {
|
|
|
2029
2050
|
methodsOpen && methodsDropdownPos && React.createElement(
|
|
2030
2051
|
'div',
|
|
2031
2052
|
{
|
|
2053
|
+
ref: methodsDropdownRef,
|
|
2032
2054
|
className: 'ide-methods-dropdown',
|
|
2033
2055
|
style: { position: 'fixed', top: methodsDropdownPos.top + 'px', right: methodsDropdownPos.right + 'px', left: 'auto', zIndex: 9900 }
|
|
2034
2056
|
},
|
|
2035
|
-
|
|
2036
|
-
? React.createElement('div', { className: 'ide-methods-dropdown-
|
|
2037
|
-
: methodsList.
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2057
|
+
methodsUnavailable
|
|
2058
|
+
? React.createElement('div', { className: 'ide-methods-dropdown-message' }, 'Outline unavailable')
|
|
2059
|
+
: methodsList.length === 0
|
|
2060
|
+
? React.createElement('div', { className: 'ide-methods-dropdown-empty' }, isTestOutline ? 'No outline entries found' : 'No methods found')
|
|
2061
|
+
: (function() {
|
|
2062
|
+
var rows = [];
|
|
2063
|
+
var visibility = null;
|
|
2064
|
+
var visibilityStartLine = null;
|
|
2065
|
+
var visibilityRows = [];
|
|
2066
|
+
var icons = { method: 'fa-code', suite: 'fa-layer-group', test: 'fa-flask' };
|
|
2067
|
+
|
|
2068
|
+
function activateEntry(entry) {
|
|
2069
|
+
setMethodsOpen(false);
|
|
2070
|
+
if (monacoRef.current) {
|
|
2071
|
+
monacoRef.current.revealLineInCenter(entry.line);
|
|
2072
|
+
monacoRef.current.setPosition({ lineNumber: entry.line, column: 1 });
|
|
2073
|
+
monacoRef.current.focus();
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
function flushVisibilityRows() {
|
|
2078
|
+
if (visibilityRows.length === 0) return;
|
|
2079
|
+
rows.push(React.createElement(
|
|
2080
|
+
'div',
|
|
2081
|
+
{
|
|
2082
|
+
key: 'visibility-group-' + visibilityStartLine,
|
|
2083
|
+
className: 'ide-methods-dropdown-visibility-group'
|
|
2084
|
+
},
|
|
2085
|
+
React.createElement(
|
|
2086
|
+
'div',
|
|
2087
|
+
{ className: 'ide-methods-dropdown-visibility' },
|
|
2088
|
+
visibility
|
|
2089
|
+
),
|
|
2090
|
+
visibilityRows
|
|
2091
|
+
));
|
|
2092
|
+
visibility = null;
|
|
2093
|
+
visibilityStartLine = null;
|
|
2094
|
+
visibilityRows = [];
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
methodsList.forEach(function(entry, entryIndex) {
|
|
2098
|
+
var depth = typeof entry.depth === 'number' ? entry.depth : 0;
|
|
2099
|
+
var row = React.createElement(
|
|
2100
|
+
'button',
|
|
2101
|
+
{
|
|
2102
|
+
key: entry.kind + '-' + entry.line,
|
|
2103
|
+
type: 'button',
|
|
2104
|
+
className: 'ide-methods-dropdown-item ide-outline-entry ide-outline-entry-' + entry.kind,
|
|
2105
|
+
'data-outline-kind': entry.kind,
|
|
2106
|
+
'data-outline-depth': depth,
|
|
2107
|
+
'aria-label': entry.name + ', line ' + entry.line,
|
|
2108
|
+
tabIndex: 0,
|
|
2109
|
+
autoFocus: entryIndex === 0,
|
|
2110
|
+
style: { paddingLeft: (12 + depth * 14) + 'px' },
|
|
2111
|
+
onClick: function() {
|
|
2112
|
+
activateEntry(entry);
|
|
2113
|
+
}
|
|
2114
|
+
},
|
|
2115
|
+
React.createElement('span', { className: 'ide-methods-dropdown-line' }, entry.line),
|
|
2116
|
+
React.createElement('i', {
|
|
2117
|
+
className: 'fas ' + (icons[entry.kind] || icons.method) + ' ide-outline-entry-icon',
|
|
2118
|
+
'aria-hidden': 'true'
|
|
2119
|
+
}),
|
|
2120
|
+
React.createElement('span', { className: 'ide-outline-entry-name' }, entry.name)
|
|
2121
|
+
);
|
|
2122
|
+
|
|
2123
|
+
if (entry.kind === 'method' && entry.visibility) {
|
|
2124
|
+
if (visibility !== entry.visibility) {
|
|
2125
|
+
flushVisibilityRows();
|
|
2126
|
+
visibility = entry.visibility;
|
|
2127
|
+
visibilityStartLine = entry.line;
|
|
2050
2128
|
}
|
|
2129
|
+
visibilityRows.push(row);
|
|
2130
|
+
} else {
|
|
2131
|
+
flushVisibilityRows();
|
|
2132
|
+
rows.push(row);
|
|
2051
2133
|
}
|
|
2052
|
-
}
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2134
|
+
});
|
|
2135
|
+
flushVisibilityRows();
|
|
2136
|
+
|
|
2137
|
+
if (methodsTruncated) {
|
|
2138
|
+
rows.push(React.createElement(
|
|
2139
|
+
'div',
|
|
2140
|
+
{ key: 'truncated', className: 'ide-methods-dropdown-message' },
|
|
2141
|
+
'Results truncated at 5,000 entries'
|
|
2142
|
+
));
|
|
2143
|
+
}
|
|
2144
|
+
return rows;
|
|
2145
|
+
})()
|
|
2057
2146
|
),
|
|
2058
2147
|
React.createElement('div', { ref: vimStatusRef, className: 'vim-statusbar', style: { display: editorPrefs.vimMode ? 'flex' : 'none', height: '22px', alignItems: 'center', padding: '0 10px', fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace", fontSize: '12px', background: 'var(--ide-statusbar-bg, #1e1e2e)', color: 'var(--ide-statusbar-fg, #9cdcfe)', borderTop: '1px solid var(--ide-border, #3e3e3e)', flexShrink: 0, userSelect: 'none', letterSpacing: '0.02em' } })
|
|
2059
2148
|
);
|
|
@@ -2073,4 +2162,4 @@ window.EditorPanel = React.memo(EditorPanel, function(prev, next) {
|
|
|
2073
2162
|
prev.testInlineVisible === next.testInlineVisible &&
|
|
2074
2163
|
prev.editorPrefs === next.editorPrefs &&
|
|
2075
2164
|
prev.monacoReady === next.monacoReady;
|
|
2076
|
-
});
|
|
2165
|
+
});
|
|
@@ -1028,6 +1028,12 @@
|
|
|
1028
1028
|
[/(\bmodule\b)(\s+)([A-Z][\w:]*)/, ['keyword.control.module', '', 'entity.name.class']],
|
|
1029
1029
|
[/\bmodule\b/, 'keyword.control.module'],
|
|
1030
1030
|
|
|
1031
|
+
// Test DSL suites, runnable examples, hooks, and helpers
|
|
1032
|
+
[/\b(describe|context|feature)(?![a-zA-Z0-9_!?=])/, 'keyword.control.test'],
|
|
1033
|
+
[/\b(test|it|specify|example|scenario)(?![a-zA-Z0-9_!?=])/, 'entity.name.function.test'],
|
|
1034
|
+
[/\b(setup|teardown|before|after|around|subject)(?![a-zA-Z0-9_!?=])/, 'support.function.test'],
|
|
1035
|
+
[/\blet!?(?=\s|\()/, 'support.function.test'],
|
|
1036
|
+
|
|
1031
1037
|
// Language literals
|
|
1032
1038
|
[/\b(nil|true|false)\b/, 'constant.language'],
|
|
1033
1039
|
[/\b(self|super)\b/, 'variable.language'],
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// A deliberately small Ruby lexer for editor outlines. It recognizes only
|
|
4
|
+
// declarations and never evaluates source; masking literals avoids false
|
|
5
|
+
// matches without needing a full Ruby parser.
|
|
6
|
+
var RubyOutline = (function () {
|
|
7
|
+
var MAX_ENTRIES = 5000;
|
|
8
|
+
var DEF_RE = /^\s*def\s+(self\.)?([a-zA-Z_][a-zA-Z0-9_?!=]*)/;
|
|
9
|
+
var VISIBILITY_RE = /^\s*(public|protected|private)\s*(?:#.*)?$/;
|
|
10
|
+
var SCOPE_RE = /^\s*(class|module)\b/;
|
|
11
|
+
var DECLARATION_RE = /^\s*((?:RSpec\.)?(?:describe|context|feature)|test|it|specify|example|scenario)\b/;
|
|
12
|
+
var SUITE_NAMES = {
|
|
13
|
+
'describe': true, 'RSpec.describe': true,
|
|
14
|
+
'context': true, 'RSpec.context': true,
|
|
15
|
+
'feature': true, 'RSpec.feature': true
|
|
16
|
+
};
|
|
17
|
+
var TEST_NAMES = { 'test': true, 'it': true, 'specify': true, 'example': true, 'scenario': true };
|
|
18
|
+
|
|
19
|
+
function indentation(line) {
|
|
20
|
+
var leading = (/^[ \t]*/.exec(line) || [''])[0];
|
|
21
|
+
return leading.replace(/\t/g, ' ').length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function popScopesAtOrInside(scopes, indent) {
|
|
25
|
+
while (scopes.length > 1 && scopes[scopes.length - 1].indent >= indent) {
|
|
26
|
+
scopes.pop();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function percentLiteralAt(line, index) {
|
|
31
|
+
if (line.charAt(index) !== '%') return null;
|
|
32
|
+
|
|
33
|
+
var type = line.charAt(index + 1);
|
|
34
|
+
var delimiterIndex = index + 1;
|
|
35
|
+
if (/[qQwWrixsI]/.test(type)) delimiterIndex++;
|
|
36
|
+
|
|
37
|
+
var open = line.charAt(delimiterIndex);
|
|
38
|
+
if (!open || /[a-zA-Z0-9_\s]/.test(open)) return null;
|
|
39
|
+
|
|
40
|
+
var pairs = { '(': ')', '[': ']', '{': '}', '<': '>' };
|
|
41
|
+
return {
|
|
42
|
+
length: delimiterIndex - index + 1,
|
|
43
|
+
open: open,
|
|
44
|
+
close: pairs[open] || open,
|
|
45
|
+
paired: !!pairs[open]
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function heredocAt(line, index) {
|
|
50
|
+
if (line.slice(index, index + 2) !== '<<') return null;
|
|
51
|
+
if (index > 0 && !/[\s(,=\[{]/.test(line.charAt(index - 1))) return null;
|
|
52
|
+
|
|
53
|
+
var match = /^<<([-~])?(?:'([^'\r\n]+)'|"([^"\r\n]+)"|`([^`\r\n]+)`|([a-zA-Z_][a-zA-Z0-9_]*))/.exec(line.slice(index));
|
|
54
|
+
if (!match) return null;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
length: match[0].length,
|
|
58
|
+
terminator: match[2] || match[3] || match[4] || match[5],
|
|
59
|
+
allowIndent: !!match[1]
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function startsSlashRegex(line, index, code) {
|
|
64
|
+
if (/\s/.test(line.charAt(index + 1))) return false;
|
|
65
|
+
|
|
66
|
+
var prefix = code.replace(/\s+$/, '');
|
|
67
|
+
if (prefix.length === 0) return true;
|
|
68
|
+
if (/[=(:,\[!&|?{};+\-*%<>~^]$/.test(prefix)) return true;
|
|
69
|
+
if (/\b(?:return|yield|when|if|unless|while|until|and|or|not)$/.test(prefix)) return true;
|
|
70
|
+
|
|
71
|
+
return /\s$/.test(line.slice(0, index)) &&
|
|
72
|
+
/[a-zA-Z_][a-zA-Z0-9_!?=]*$/.test(prefix);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function maskRubyLine(line, state) {
|
|
76
|
+
var code = '';
|
|
77
|
+
var heredocs = [];
|
|
78
|
+
var i = 0;
|
|
79
|
+
|
|
80
|
+
while (i < line.length) {
|
|
81
|
+
var ch = line.charAt(i);
|
|
82
|
+
|
|
83
|
+
if (state) {
|
|
84
|
+
var maskedCharacter = ' ';
|
|
85
|
+
|
|
86
|
+
if (state.escaped) {
|
|
87
|
+
state.escaped = false;
|
|
88
|
+
} else if (ch === '\\') {
|
|
89
|
+
state.escaped = true;
|
|
90
|
+
} else if (state.regex && ch === '[') {
|
|
91
|
+
state.inCharacterClass = true;
|
|
92
|
+
} else if (state.regex && ch === ']') {
|
|
93
|
+
state.inCharacterClass = false;
|
|
94
|
+
} else if (state.regex && ch === '/' && !state.inCharacterClass) {
|
|
95
|
+
state = null;
|
|
96
|
+
} else if (state.paired && ch === state.open) {
|
|
97
|
+
state.depth++;
|
|
98
|
+
} else if (!state.regex && ch === state.close) {
|
|
99
|
+
if (state.paired) state.depth--;
|
|
100
|
+
if (!state.paired || state.depth === 0) state = null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (!state) maskedCharacter = 'x';
|
|
104
|
+
code += maskedCharacter;
|
|
105
|
+
i++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (ch === '#') break;
|
|
110
|
+
|
|
111
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
112
|
+
state = { open: ch, close: ch, paired: false, depth: 1, escaped: false };
|
|
113
|
+
code += ' ';
|
|
114
|
+
i++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (ch === '/' && startsSlashRegex(line, i, code)) {
|
|
119
|
+
state = {
|
|
120
|
+
open: '/', close: '/', paired: false, depth: 1, escaped: false,
|
|
121
|
+
regex: true, inCharacterClass: false
|
|
122
|
+
};
|
|
123
|
+
code += ' ';
|
|
124
|
+
i++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
var percentLiteral = percentLiteralAt(line, i);
|
|
129
|
+
if (percentLiteral) {
|
|
130
|
+
state = {
|
|
131
|
+
open: percentLiteral.open, close: percentLiteral.close,
|
|
132
|
+
paired: percentLiteral.paired, depth: 1, escaped: false
|
|
133
|
+
};
|
|
134
|
+
code += new Array(percentLiteral.length + 1).join(' ');
|
|
135
|
+
i += percentLiteral.length;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
var heredoc = heredocAt(line, i);
|
|
140
|
+
if (heredoc) {
|
|
141
|
+
heredocs.push({ terminator: heredoc.terminator, allowIndent: heredoc.allowIndent });
|
|
142
|
+
code += new Array(heredoc.length + 1).join(' ');
|
|
143
|
+
i += heredoc.length;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
code += ch;
|
|
148
|
+
i++;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (state) state.escaped = false;
|
|
152
|
+
return { code: code, state: state, heredocs: heredocs };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function addEntry(entries, entry) {
|
|
156
|
+
if (entries.length >= MAX_ENTRIES) return false;
|
|
157
|
+
entries.push(entry);
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function cloneLexicalState(state) {
|
|
162
|
+
if (!state) return null;
|
|
163
|
+
return {
|
|
164
|
+
open: state.open,
|
|
165
|
+
close: state.close,
|
|
166
|
+
paired: state.paired,
|
|
167
|
+
depth: state.depth,
|
|
168
|
+
escaped: state.escaped,
|
|
169
|
+
regex: state.regex,
|
|
170
|
+
inCharacterClass: state.inCharacterClass
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function cloneHeredocs(heredocs) {
|
|
175
|
+
return heredocs.map(function (heredoc) {
|
|
176
|
+
return { terminator: heredoc.terminator, allowIndent: heredoc.allowIndent };
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function testPath(path) {
|
|
181
|
+
return /(^|\/)test\/.*_test\.rb$/.test(path) ||
|
|
182
|
+
/(^|\/)spec\/.*_spec\.rb$/.test(path) ||
|
|
183
|
+
/_test\.rb$/.test(path) ||
|
|
184
|
+
/_spec\.rb$/.test(path);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function declarationDescription(raw, code, declaration, kind, lineNumber) {
|
|
188
|
+
var rest = raw.slice(raw.indexOf(declaration) + declaration.length);
|
|
189
|
+
var codeRest = code.slice(code.indexOf(declaration) + declaration.length);
|
|
190
|
+
var quoted = /^\s*\(?\s*(['"])((?:\\.|[^\\])*?)\1/.exec(rest);
|
|
191
|
+
var constant = /^\s*\(?\s*([A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*)(?=\s*(?:,|\)|\bdo\b|\{))/.exec(rest);
|
|
192
|
+
var descriptor = codeRest.replace(/(?:\bdo\b|\{)[\s\S]*$/, '').replace(/[(){}\s]/g, '');
|
|
193
|
+
|
|
194
|
+
if (quoted && quoted[2].indexOf('\n') === -1 &&
|
|
195
|
+
(quoted[1] === "'" || quoted[2].indexOf('#{') === -1)) return quoted[2];
|
|
196
|
+
if (constant) return constant[1];
|
|
197
|
+
if (quoted || /[a-zA-Z_:]/.test(descriptor) || /<</.test(rest)) {
|
|
198
|
+
return kind === 'suite' ? 'suite at line ' + lineNumber : 'test at line ' + lineNumber;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function declarationStatus(code) {
|
|
204
|
+
var parenDepth = 0;
|
|
205
|
+
var bracketDepth = 0;
|
|
206
|
+
var hashDepth = 0;
|
|
207
|
+
var i = 0;
|
|
208
|
+
|
|
209
|
+
while (i < code.length) {
|
|
210
|
+
var ch = code.charAt(i);
|
|
211
|
+
|
|
212
|
+
if (ch === '(') {
|
|
213
|
+
parenDepth++;
|
|
214
|
+
} else if (ch === ')') {
|
|
215
|
+
if (parenDepth > 0) parenDepth--;
|
|
216
|
+
} else if (ch === '[') {
|
|
217
|
+
bracketDepth++;
|
|
218
|
+
} else if (ch === ']') {
|
|
219
|
+
if (bracketDepth > 0) bracketDepth--;
|
|
220
|
+
} else if (ch === '{') {
|
|
221
|
+
if (parenDepth === 0 && bracketDepth === 0 && hashDepth === 0) {
|
|
222
|
+
var beforeBrace = code.slice(0, i).replace(/\s+$/, '');
|
|
223
|
+
if (!/(?:[:,=]|\*\*|=>)$/.test(beforeBrace)) {
|
|
224
|
+
return { complete: true, canContinue: false };
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
hashDepth++;
|
|
228
|
+
} else if (ch === '}') {
|
|
229
|
+
if (hashDepth > 0) hashDepth--;
|
|
230
|
+
} else if (parenDepth === 0 && bracketDepth === 0 && hashDepth === 0 &&
|
|
231
|
+
code.slice(i, i + 2) === 'do' &&
|
|
232
|
+
(i === 0 || !/[a-zA-Z0-9_!:]/.test(code.charAt(i - 1))) &&
|
|
233
|
+
!/[a-zA-Z0-9_!?=]/.test(code.charAt(i + 2)) &&
|
|
234
|
+
!/^\s*:/.test(code.slice(i + 2))) {
|
|
235
|
+
return { complete: true, canContinue: false };
|
|
236
|
+
}
|
|
237
|
+
i++;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
var trimmed = code.replace(/\s+$/, '');
|
|
241
|
+
return {
|
|
242
|
+
complete: false,
|
|
243
|
+
canContinue: parenDepth > 0 || bracketDepth > 0 || hashDepth > 0 ||
|
|
244
|
+
/(?:[,\\]|[+\-*\/%&|.=<>?:])$/.test(trimmed)
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function scanDeclarationHeader(lines, startIndex, rawHeader, codeHeader, state, heredocs, inBlockComment) {
|
|
249
|
+
var headerEnd = startIndex;
|
|
250
|
+
var localState = cloneLexicalState(state);
|
|
251
|
+
var localHeredocs = cloneHeredocs(heredocs);
|
|
252
|
+
var localBlockComment = inBlockComment;
|
|
253
|
+
var status = declarationStatus(codeHeader);
|
|
254
|
+
if (!status.complete && (localState || localHeredocs.length > 0 || localBlockComment)) {
|
|
255
|
+
status.canContinue = true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
while (!status.complete && status.canContinue &&
|
|
259
|
+
headerEnd + 1 < lines.length && headerEnd - startIndex < 7) {
|
|
260
|
+
headerEnd++;
|
|
261
|
+
var nextLine = String(lines[headerEnd] || '').replace(/\r?\n$/, '');
|
|
262
|
+
var nextCode = '';
|
|
263
|
+
|
|
264
|
+
if (localBlockComment) {
|
|
265
|
+
if (/^=end\b/.test(nextLine)) localBlockComment = false;
|
|
266
|
+
} else if (localHeredocs.length > 0) {
|
|
267
|
+
var pendingHeredoc = localHeredocs[0];
|
|
268
|
+
var candidateTerminator = pendingHeredoc.allowIndent ?
|
|
269
|
+
nextLine.replace(/^\s+/, '') : nextLine;
|
|
270
|
+
if (candidateTerminator === pendingHeredoc.terminator) localHeredocs.shift();
|
|
271
|
+
} else {
|
|
272
|
+
var nextMasked = maskRubyLine(nextLine, localState);
|
|
273
|
+
localState = nextMasked.state;
|
|
274
|
+
nextCode = nextMasked.code;
|
|
275
|
+
|
|
276
|
+
if (/^=begin\b/.test(nextCode)) {
|
|
277
|
+
localBlockComment = true;
|
|
278
|
+
nextCode = '';
|
|
279
|
+
} else {
|
|
280
|
+
for (var h = 0; h < nextMasked.heredocs.length; h++) {
|
|
281
|
+
localHeredocs.push(nextMasked.heredocs[h]);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
rawHeader += '\n' + nextLine;
|
|
287
|
+
codeHeader += '\n' + nextCode;
|
|
288
|
+
status = declarationStatus(codeHeader);
|
|
289
|
+
if (!status.complete && (localState || localHeredocs.length > 0 || localBlockComment)) {
|
|
290
|
+
status.canContinue = true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
complete: status.complete,
|
|
296
|
+
rawHeader: rawHeader,
|
|
297
|
+
codeHeader: codeHeader,
|
|
298
|
+
headerEnd: headerEnd,
|
|
299
|
+
state: localState,
|
|
300
|
+
heredocs: localHeredocs,
|
|
301
|
+
inBlockComment: localBlockComment
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function parse(lines, options) {
|
|
306
|
+
var entries = [];
|
|
307
|
+
var scopes = [{ indent: -1, visibility: null }];
|
|
308
|
+
var suites = [];
|
|
309
|
+
var heredocTerminators = [];
|
|
310
|
+
var lexicalState = null;
|
|
311
|
+
var inBlockComment = false;
|
|
312
|
+
var path = String((options || {}).path || '');
|
|
313
|
+
var allowTestDsl = testPath(path);
|
|
314
|
+
var stopped = false;
|
|
315
|
+
|
|
316
|
+
for (var i = 0; i < lines.length && !stopped; i++) {
|
|
317
|
+
var line = String(lines[i] || '').replace(/\r?\n$/, '');
|
|
318
|
+
var codeLine;
|
|
319
|
+
var masked;
|
|
320
|
+
|
|
321
|
+
if (inBlockComment) {
|
|
322
|
+
if (/^=end\b/.test(line)) inBlockComment = false;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (heredocTerminators.length > 0) {
|
|
327
|
+
var pendingHeredoc = heredocTerminators[0];
|
|
328
|
+
var candidateTerminator = pendingHeredoc.allowIndent ? line.replace(/^\s+/, '') : line;
|
|
329
|
+
if (candidateTerminator === pendingHeredoc.terminator) heredocTerminators.shift();
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
masked = maskRubyLine(line, lexicalState);
|
|
334
|
+
lexicalState = masked.state;
|
|
335
|
+
codeLine = masked.code;
|
|
336
|
+
|
|
337
|
+
if (/^=begin\b/.test(codeLine)) {
|
|
338
|
+
inBlockComment = true;
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (var h = 0; h < masked.heredocs.length; h++) heredocTerminators.push(masked.heredocs[h]);
|
|
343
|
+
if (/^\s*$/.test(codeLine)) continue;
|
|
344
|
+
|
|
345
|
+
var indent = indentation(line);
|
|
346
|
+
while (suites.length > 0 && indent <= suites[suites.length - 1].indent) suites.pop();
|
|
347
|
+
|
|
348
|
+
if (SCOPE_RE.test(codeLine)) {
|
|
349
|
+
popScopesAtOrInside(scopes, indent);
|
|
350
|
+
scopes.push({ indent: indent, visibility: null });
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
var visibilityMatch = VISIBILITY_RE.exec(codeLine);
|
|
355
|
+
if (visibilityMatch) {
|
|
356
|
+
popScopesAtOrInside(scopes, indent);
|
|
357
|
+
scopes[scopes.length - 1].visibility = visibilityMatch[1];
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
var methodMatch = DEF_RE.exec(codeLine);
|
|
362
|
+
if (methodMatch) {
|
|
363
|
+
popScopesAtOrInside(scopes, indent);
|
|
364
|
+
if (!addEntry(entries, {
|
|
365
|
+
line: i + 1,
|
|
366
|
+
name: (methodMatch[1] || '') + methodMatch[2],
|
|
367
|
+
kind: 'method',
|
|
368
|
+
depth: suites.length,
|
|
369
|
+
visibility: methodMatch[1] ? 'public' : scopes[scopes.length - 1].visibility
|
|
370
|
+
})) stopped = true;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (!allowTestDsl) continue;
|
|
375
|
+
|
|
376
|
+
var declarationMatch = DECLARATION_RE.exec(codeLine);
|
|
377
|
+
if (!declarationMatch) continue;
|
|
378
|
+
|
|
379
|
+
var declaration = declarationMatch[1];
|
|
380
|
+
var kind = SUITE_NAMES[declaration] ? 'suite' : (TEST_NAMES[declaration] ? 'test' : null);
|
|
381
|
+
if (!kind) continue;
|
|
382
|
+
|
|
383
|
+
var rawHeader = line;
|
|
384
|
+
var codeHeader = codeLine;
|
|
385
|
+
var headerEnd = i;
|
|
386
|
+
var headerScan = scanDeclarationHeader(
|
|
387
|
+
lines, i, rawHeader, codeHeader, lexicalState,
|
|
388
|
+
heredocTerminators, inBlockComment
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
if (!headerScan.complete) continue;
|
|
392
|
+
if (headerScan.headerEnd > i) {
|
|
393
|
+
rawHeader = headerScan.rawHeader;
|
|
394
|
+
codeHeader = headerScan.codeHeader;
|
|
395
|
+
headerEnd = headerScan.headerEnd;
|
|
396
|
+
lexicalState = headerScan.state;
|
|
397
|
+
heredocTerminators = headerScan.heredocs;
|
|
398
|
+
inBlockComment = headerScan.inBlockComment;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
var name = declarationDescription(rawHeader, codeHeader, declaration, kind, i + 1);
|
|
402
|
+
if (!name) continue;
|
|
403
|
+
|
|
404
|
+
if (!addEntry(entries, {
|
|
405
|
+
line: i + 1,
|
|
406
|
+
name: name,
|
|
407
|
+
kind: kind,
|
|
408
|
+
depth: suites.length,
|
|
409
|
+
visibility: null
|
|
410
|
+
})) {
|
|
411
|
+
stopped = true;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (kind === 'suite' && !/(?:\bdo\b|\{)[\s\S]*\bend\b/.test(codeHeader)) {
|
|
416
|
+
suites.push({ indent: indent });
|
|
417
|
+
}
|
|
418
|
+
i = headerEnd;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return { entries: entries, truncated: entries.length >= MAX_ENTRIES && stopped };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return { parse: parse, isTestPath: testPath };
|
|
425
|
+
})();
|
|
426
|
+
|
|
427
|
+
window.RubyOutline = RubyOutline;
|
|
@@ -1990,6 +1990,10 @@ html, body, #mbeditor-root {
|
|
|
1990
1990
|
display: flex;
|
|
1991
1991
|
align-items: center;
|
|
1992
1992
|
gap: 8px;
|
|
1993
|
+
width: 100%;
|
|
1994
|
+
border: 0;
|
|
1995
|
+
border-radius: 0;
|
|
1996
|
+
background: transparent;
|
|
1993
1997
|
padding: 5px 12px;
|
|
1994
1998
|
cursor: pointer;
|
|
1995
1999
|
color: #ccc;
|
|
@@ -1998,13 +2002,20 @@ html, body, #mbeditor-root {
|
|
|
1998
2002
|
white-space: nowrap;
|
|
1999
2003
|
overflow: hidden;
|
|
2000
2004
|
text-overflow: ellipsis;
|
|
2005
|
+
text-align: left;
|
|
2001
2006
|
}
|
|
2002
2007
|
|
|
2003
|
-
.ide-methods-dropdown-item:hover
|
|
2008
|
+
.ide-methods-dropdown-item:hover,
|
|
2009
|
+
.ide-methods-dropdown-item:focus {
|
|
2004
2010
|
background: #094771;
|
|
2005
2011
|
color: #fff;
|
|
2006
2012
|
}
|
|
2007
2013
|
|
|
2014
|
+
.ide-methods-dropdown-item:focus {
|
|
2015
|
+
outline: 1px solid #75beff;
|
|
2016
|
+
outline-offset: -1px;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2008
2019
|
.ide-methods-dropdown-line {
|
|
2009
2020
|
color: #858585;
|
|
2010
2021
|
font-size: 11px;
|
|
@@ -2021,6 +2032,42 @@ html, body, #mbeditor-root {
|
|
|
2021
2032
|
font-style: italic;
|
|
2022
2033
|
}
|
|
2023
2034
|
|
|
2035
|
+
.ide-methods-dropdown-visibility {
|
|
2036
|
+
position: sticky;
|
|
2037
|
+
top: 0;
|
|
2038
|
+
z-index: 1;
|
|
2039
|
+
padding: 4px 12px;
|
|
2040
|
+
background: #252526;
|
|
2041
|
+
border-bottom: 1px solid #3a3a3a;
|
|
2042
|
+
color: #9cdcfe;
|
|
2043
|
+
font-size: 10px;
|
|
2044
|
+
font-weight: 600;
|
|
2045
|
+
letter-spacing: 0.06em;
|
|
2046
|
+
text-transform: uppercase;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
.ide-methods-dropdown-visibility-group {
|
|
2050
|
+
position: relative;
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
.ide-outline-entry-icon {
|
|
2054
|
+
width: 13px;
|
|
2055
|
+
flex-shrink: 0;
|
|
2056
|
+
text-align: center;
|
|
2057
|
+
color: #858585;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
.ide-outline-entry-suite .ide-outline-entry-icon { color: #dcb67a; }
|
|
2061
|
+
.ide-outline-entry-test .ide-outline-entry-icon { color: #8ab4f8; }
|
|
2062
|
+
.ide-outline-entry-name { overflow: hidden; text-overflow: ellipsis; }
|
|
2063
|
+
|
|
2064
|
+
.ide-methods-dropdown-message {
|
|
2065
|
+
padding: 8px 12px;
|
|
2066
|
+
color: #858585;
|
|
2067
|
+
font-size: 12px;
|
|
2068
|
+
font-style: italic;
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2024
2071
|
|
|
2025
2072
|
|
|
2026
2073
|
.project-action-btn {
|
|
@@ -32,23 +32,17 @@ module Mbeditor
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
# Expand path and confirm it's inside workspace_root.
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
35
|
+
# SafePath follows every symlink on the way — including dangling ones, which
|
|
36
|
+
# an existence walk would skip past — so a symlink inside the workspace
|
|
37
|
+
# cannot escape the sandbox regardless of whether the target exists yet
|
|
38
|
+
# (create / rename paths).
|
|
38
39
|
def resolve_path(raw)
|
|
39
40
|
return nil if raw.blank?
|
|
40
41
|
|
|
41
42
|
root = workspace_root.to_s
|
|
42
43
|
full = File.expand_path(raw.to_s, root)
|
|
43
44
|
return nil unless full.start_with?("#{root}/") || full == root
|
|
44
|
-
|
|
45
|
-
# Walk up to the nearest existing ancestor (could be the path itself,
|
|
46
|
-
# its parent directory, or ultimately the workspace root).
|
|
47
|
-
check = full
|
|
48
|
-
check = File.dirname(check) until File.exist?(check)
|
|
49
|
-
real_root = File.realpath(root)
|
|
50
|
-
real = File.realpath(check)
|
|
51
|
-
return nil unless real.start_with?("#{real_root}/") || real == real_root
|
|
45
|
+
return nil unless SafePath.within?(root, full)
|
|
52
46
|
|
|
53
47
|
full
|
|
54
48
|
rescue Errno::EACCES
|
|
@@ -1098,7 +1098,7 @@ module Mbeditor
|
|
|
1098
1098
|
rel = relative_path(full_path)
|
|
1099
1099
|
return true if rel.blank?
|
|
1100
1100
|
|
|
1101
|
-
ExclusionMatcher.new(Mbeditor.configuration.excluded_paths).excluded?(rel)
|
|
1101
|
+
ExclusionMatcher.new(Mbeditor.configuration.excluded_paths, root: workspace_root).excluded?(rel)
|
|
1102
1102
|
end
|
|
1103
1103
|
|
|
1104
1104
|
def ruby_def_include_dirs
|
|
@@ -1,13 +1,98 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mbeditor
|
|
4
|
+
# Decides whether a workspace-relative path falls under a configured
|
|
5
|
+
# exclusion (`Mbeditor.configuration.excluded_paths`).
|
|
6
|
+
#
|
|
7
|
+
# Comparison is normalized rather than byte-for-byte, because the filesystem
|
|
8
|
+
# the workspace lives on may resolve two different spellings of a name to the
|
|
9
|
+
# same file:
|
|
10
|
+
#
|
|
11
|
+
# * Case. APFS/HFS+ (and NTFS) resolve "TMP/cache.txt" to the excluded
|
|
12
|
+
# "tmp/", and ".GIT/hooks/post-checkout" to the real ".git/hooks/" — the
|
|
13
|
+
# latter is arbitrary code execution on the next git operation. Folding is
|
|
14
|
+
# applied only when the filesystem backing the workspace really is
|
|
15
|
+
# case-insensitive, probed at runtime: a case-sensitive volume can be
|
|
16
|
+
# mounted on macOS, and folding there would hide legitimately distinct
|
|
17
|
+
# paths.
|
|
18
|
+
#
|
|
19
|
+
# * Unicode normalization. APFS stores one file for the NFC and NFD
|
|
20
|
+
# spellings of a name, so an NFD request path slips past an NFC pattern.
|
|
21
|
+
# NFC folding is applied unconditionally: filesystems that do keep the two
|
|
22
|
+
# spellings apart are rare enough, and the cost there is over-exclusion
|
|
23
|
+
# (hiding a path) rather than the write-through this class exists to
|
|
24
|
+
# prevent.
|
|
4
25
|
class ExclusionMatcher
|
|
5
|
-
|
|
6
|
-
|
|
26
|
+
MUTEX = Mutex.new
|
|
27
|
+
private_constant :MUTEX
|
|
28
|
+
|
|
29
|
+
# Roots are stable for the life of a process; the bound only keeps the test
|
|
30
|
+
# suite (a fresh tmpdir workspace per test) from growing the memo forever.
|
|
31
|
+
MAX_PROBE_CACHE = 256
|
|
32
|
+
private_constant :MAX_PROBE_CACHE
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# Whether the filesystem backing +root+ resolves paths case-insensitively.
|
|
36
|
+
# Probed once per root, then memoized for the life of the process.
|
|
37
|
+
def case_insensitive_filesystem?(root)
|
|
38
|
+
key = File.expand_path(root.to_s)
|
|
39
|
+
|
|
40
|
+
MUTEX.synchronize do
|
|
41
|
+
@probe_cache ||= {}
|
|
42
|
+
return @probe_cache[key] if @probe_cache.key?(key)
|
|
43
|
+
|
|
44
|
+
@probe_cache = {} if @probe_cache.size >= MAX_PROBE_CACHE
|
|
45
|
+
@probe_cache[key] = probe_case_insensitive(key)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def reset_filesystem_probe!
|
|
50
|
+
MUTEX.synchronize { @probe_cache = {} }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# Asks the filesystem directly: look up a real directory under its own
|
|
56
|
+
# name and under that name with the case flipped, and compare device +
|
|
57
|
+
# inode (that is what File.identical? does). They only agree when the
|
|
58
|
+
# filesystem folded the two spellings onto one directory.
|
|
59
|
+
#
|
|
60
|
+
# Climbs to the nearest ancestor whose name actually contains a cased
|
|
61
|
+
# character, since a name like "42" flips to itself and proves nothing.
|
|
62
|
+
# When nothing can be determined — no cased ancestor, a root that does not
|
|
63
|
+
# exist, a permission error — it reports case-insensitive, which
|
|
64
|
+
# over-excludes rather than leaving the hole open.
|
|
65
|
+
def probe_case_insensitive(root)
|
|
66
|
+
return true unless File.directory?(root)
|
|
67
|
+
|
|
68
|
+
path = root
|
|
69
|
+
loop do
|
|
70
|
+
parent = File.dirname(path)
|
|
71
|
+
base = File.basename(path)
|
|
72
|
+
flipped = base.swapcase
|
|
73
|
+
|
|
74
|
+
return File.identical?(path, File.join(parent, flipped)) unless flipped == base
|
|
75
|
+
break if parent == path
|
|
76
|
+
|
|
77
|
+
path = parent
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
true
|
|
81
|
+
rescue SystemCallError
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# +root+ is the workspace the paths are relative to; it decides only whether
|
|
87
|
+
# case is folded. Defaults to the resolved workspace root.
|
|
88
|
+
def initialize(patterns, root: nil)
|
|
89
|
+
@fold_case = self.class.case_insensitive_filesystem?(root || WorkspaceRootResolver.call)
|
|
90
|
+
@patterns = patterns.map { |pattern| normalize(pattern.to_s) }.reject(&:empty?)
|
|
7
91
|
end
|
|
8
92
|
|
|
9
93
|
def excluded?(relative_path)
|
|
10
|
-
|
|
94
|
+
rel = normalize(relative_path.to_s)
|
|
95
|
+
@patterns.any? { |pattern| matches?(pattern, rel) }
|
|
11
96
|
end
|
|
12
97
|
|
|
13
98
|
private
|
|
@@ -19,5 +104,22 @@ module Mbeditor
|
|
|
19
104
|
File.basename(rel) == pattern || rel.split("/").include?(pattern)
|
|
20
105
|
end
|
|
21
106
|
end
|
|
107
|
+
|
|
108
|
+
# "/" is ASCII and survives both steps, so the caller can normalize a whole
|
|
109
|
+
# path in one pass and split it afterwards.
|
|
110
|
+
def normalize(str)
|
|
111
|
+
# ASCII is already NFC, so only the case step can apply.
|
|
112
|
+
return @fold_case ? str.downcase : str if str.ascii_only?
|
|
113
|
+
|
|
114
|
+
utf8 = str.encoding == Encoding::UTF_8 ? str : str.dup.force_encoding(Encoding::UTF_8)
|
|
115
|
+
# Undecodable bytes become U+FFFD. This is a comparison key, never a path
|
|
116
|
+
# we open, and scrubbing keeps a malformed name from skipping the fold
|
|
117
|
+
# (and raising) on its way past an exclusion.
|
|
118
|
+
utf8 = utf8.scrub unless utf8.valid_encoding?
|
|
119
|
+
utf8 = utf8.unicode_normalize(:nfc)
|
|
120
|
+
@fold_case ? utf8.downcase : utf8
|
|
121
|
+
rescue ArgumentError, Encoding::CompatibilityError
|
|
122
|
+
str
|
|
123
|
+
end
|
|
22
124
|
end
|
|
23
125
|
end
|
|
@@ -139,8 +139,8 @@ module Mbeditor
|
|
|
139
139
|
# Resolve a file path safely within repo_path. Returns full path string or
|
|
140
140
|
# nil if the path escapes the root.
|
|
141
141
|
#
|
|
142
|
-
#
|
|
143
|
-
#
|
|
142
|
+
# Follows symlinks (via SafePath, including dangling ones) so that a symlink
|
|
143
|
+
# inside the repo cannot escape it, mirroring
|
|
144
144
|
# ApplicationController#resolve_path. When repo_path is not a real directory
|
|
145
145
|
# (e.g. unit tests with synthetic roots) the symlink check is skipped, since
|
|
146
146
|
# there is nothing on disk to resolve and repo_path is server-controlled.
|
|
@@ -151,14 +151,7 @@ module Mbeditor
|
|
|
151
151
|
full = File.expand_path(relative.to_s, root)
|
|
152
152
|
return nil unless full.start_with?("#{root}/") || full == root
|
|
153
153
|
return full unless File.directory?(root)
|
|
154
|
-
|
|
155
|
-
# Walk up to the nearest existing ancestor, then realpath both it and the
|
|
156
|
-
# root and confirm the resolved target is still inside the resolved root.
|
|
157
|
-
check = full
|
|
158
|
-
check = File.dirname(check) until File.exist?(check)
|
|
159
|
-
real_root = File.realpath(root)
|
|
160
|
-
real = File.realpath(check)
|
|
161
|
-
return nil unless real.start_with?("#{real_root}/") || real == real_root
|
|
154
|
+
return nil unless SafePath.within?(root, full)
|
|
162
155
|
|
|
163
156
|
full
|
|
164
157
|
rescue Errno::EACCES
|
|
@@ -181,7 +181,7 @@ module Mbeditor
|
|
|
181
181
|
@symbol = symbol
|
|
182
182
|
@excluded_paths = Array(excluded_paths)
|
|
183
183
|
@included_dirs = Array(included_dirs)
|
|
184
|
-
@exclusion_matcher = ExclusionMatcher.new(@excluded_paths)
|
|
184
|
+
@exclusion_matcher = ExclusionMatcher.new(@excluded_paths, root: @workspace_root)
|
|
185
185
|
@shared_cache = self.class.send(:file_cache)
|
|
186
186
|
@shared_mutex = self.class.send(:mutex)
|
|
187
187
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mbeditor
|
|
4
|
+
# Containment check for paths that may not exist yet.
|
|
5
|
+
#
|
|
6
|
+
# +File.realpath+ refuses to resolve a path whose final component is missing,
|
|
7
|
+
# so callers that create files have to reason about the nearest existing
|
|
8
|
+
# ancestor instead. Doing that with +File.exist?+ is unsafe: it *follows*
|
|
9
|
+
# symlinks, so a symlink pointing at a missing target reports false and an
|
|
10
|
+
# existence walk steps straight past it, back to the safely-contained parent.
|
|
11
|
+
# A subsequent +File.open+ follows the link and writes outside the root.
|
|
12
|
+
#
|
|
13
|
+
# SafePath resolves the link chain itself with +File.symlink?+/+File.readlink+
|
|
14
|
+
# (both lstat-based, neither follows) so a dangling symlink is judged by where
|
|
15
|
+
# it points, not by whether that target happens to exist yet.
|
|
16
|
+
module SafePath
|
|
17
|
+
# Guards against symlink cycles, which surface as a path that neither
|
|
18
|
+
# exists nor terminates.
|
|
19
|
+
MAX_SYMLINK_HOPS = 32
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# True when +path+ resolves to a location inside +root+, following every
|
|
24
|
+
# symlink component including dangling ones. +root+ must exist.
|
|
25
|
+
def within?(root, path)
|
|
26
|
+
real_root = File.realpath(root.to_s)
|
|
27
|
+
target = canonical(path.to_s)
|
|
28
|
+
return false unless target
|
|
29
|
+
|
|
30
|
+
target == real_root || target.start_with?("#{real_root}/")
|
|
31
|
+
rescue SystemCallError
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The filesystem location a write to +path+ would actually land on, with
|
|
36
|
+
# all symlinks expanded. Returns nil when the link chain cycles or exceeds
|
|
37
|
+
# MAX_SYMLINK_HOPS.
|
|
38
|
+
def canonical(path, hops = 0)
|
|
39
|
+
return nil if hops > MAX_SYMLINK_HOPS
|
|
40
|
+
# Anything that exists (including a symlink with a live target) resolves
|
|
41
|
+
# normally; realpath expands the whole chain for us.
|
|
42
|
+
return File.realpath(path) if File.exist?(path)
|
|
43
|
+
|
|
44
|
+
parent = File.dirname(path)
|
|
45
|
+
return nil if parent == path # ran off the top without finding anything
|
|
46
|
+
|
|
47
|
+
real_parent = canonical(parent, hops)
|
|
48
|
+
return nil unless real_parent
|
|
49
|
+
|
|
50
|
+
if File.symlink?(path)
|
|
51
|
+
canonical(File.absolute_path(File.readlink(path), real_parent), hops + 1)
|
|
52
|
+
else
|
|
53
|
+
File.join(real_parent, File.basename(path))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -100,7 +100,7 @@ module Mbeditor
|
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
pattern = build_pattern(query, use_regex: use_regex, match_case: match_case, whole_word: whole_word)
|
|
103
|
-
matcher = ExclusionMatcher.new(excluded_paths)
|
|
103
|
+
matcher = ExclusionMatcher.new(excluded_paths, root: workspace_root)
|
|
104
104
|
replaced_count = 0
|
|
105
105
|
files_affected = []
|
|
106
106
|
errors = []
|
|
@@ -182,7 +182,7 @@ module Mbeditor
|
|
|
182
182
|
tier = pick_tier(root)
|
|
183
183
|
env, args = build_command(tier, root, query, use_regex: use_regex, match_case: match_case,
|
|
184
184
|
whole_word: whole_word, excluded_paths: excluded_paths, paths: paths)
|
|
185
|
-
matcher = ExclusionMatcher.new(excluded_paths)
|
|
185
|
+
matcher = ExclusionMatcher.new(excluded_paths, root: root)
|
|
186
186
|
results = []
|
|
187
187
|
timed_out = false
|
|
188
188
|
timeout_secs = Mbeditor.configuration.search_timeout
|
data/lib/mbeditor/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mbeditor
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Oliver Noonan
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -82,6 +82,7 @@ files:
|
|
|
82
82
|
- app/assets/javascripts/mbeditor/git_service.js
|
|
83
83
|
- app/assets/javascripts/mbeditor/history_service.js
|
|
84
84
|
- app/assets/javascripts/mbeditor/log_service.js
|
|
85
|
+
- app/assets/javascripts/mbeditor/ruby_outline.js
|
|
85
86
|
- app/assets/javascripts/mbeditor/search_service.js
|
|
86
87
|
- app/assets/javascripts/mbeditor/tab_manager.js
|
|
87
88
|
- app/assets/javascripts/mbeditor/websocket_service.js
|
|
@@ -118,6 +119,7 @@ files:
|
|
|
118
119
|
- app/services/mbeditor/redmine_service.rb
|
|
119
120
|
- app/services/mbeditor/ri_definition_service.rb
|
|
120
121
|
- app/services/mbeditor/ruby_definition_service.rb
|
|
122
|
+
- app/services/mbeditor/safe_path.rb
|
|
121
123
|
- app/services/mbeditor/schema_service.rb
|
|
122
124
|
- app/services/mbeditor/search_replace_service.rb
|
|
123
125
|
- app/services/mbeditor/test_runner_service.rb
|