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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2f74579627da44a31db150cb5de08c5060ccad0a68e9ac47603039b84849fff2
4
- data.tar.gz: d2fa6b611b1a1804e26748281f367bd3f2dfc20796d0459defb9b05d65583fa1
3
+ metadata.gz: 2612297fd4760bf7660ab89589c549ff6a815df395e4f57fb48d6f60c1c519b8
4
+ data.tar.gz: d9bf030f5f056754bfe8c633253aba3013c7405aaba259628c0e7aa5e7b19aff
5
5
  SHA512:
6
- metadata.gz: ab9d1c840c0153f07f3f1989874bca8025d59921615597be5eae9ba83b5eb9c536a554d50bb934c4d2c6202d8d89b119179241cba67c4c21bdf81f98491546d3
7
- data.tar.gz: e5328c15e5c250c55a0d02eba2a38e2d752bf8c813d9e740181cd3e0ff86e5d79fa1bfb2e5cb5ad566328cd6b17b23faf2952edc0eed4564ec1311b86ef649f3
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(null);
91
+ var _useState15 = useState(false);
92
92
  var _useState16 = _slicedToArray(_useState15, 2);
93
- var methodsDropdownPos = _useState16[0];
94
- var setMethodsDropdownPos = _useState16[1];
93
+ var methodsTruncated = _useState16[0];
94
+ var setMethodsTruncated = _useState16[1];
95
95
 
96
- var methodsBtnRef = useRef(null);
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 pageStartLine = _useState18[0];
102
- var setPageStartLine = _useState18[1];
98
+ var methodsUnavailable = _useState18[0];
99
+ var setMethodsUnavailable = _useState18[1];
103
100
 
104
- var _useState19 = useState(tab.lineCount || 0);
101
+ var _useState19 = useState(null);
105
102
  var _useState20 = _slicedToArray(_useState19, 2);
106
- var pageLineCount = _useState20[0];
107
- var setPageLineCount = _useState20[1];
103
+ var methodsDropdownPos = _useState20[0];
104
+ var setMethodsDropdownPos = _useState20[1];
105
+
106
+ var methodsBtnRef = useRef(null);
107
+ var methodsDropdownRef = useRef(null);
108
108
 
109
- var _useState21 = useState(tab.totalLines || 0);
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 pageTotalLines = _useState22[0];
112
- var setPageTotalLines = _useState22[1];
112
+ var pageStartLine = _useState22[0];
113
+ var setPageStartLine = _useState22[1];
113
114
 
114
- var _useState23 = useState(tab.totalBytes || 0);
115
+ var _useState23 = useState(tab.lineCount || 0);
115
116
  var _useState24 = _slicedToArray(_useState23, 2);
116
- var pageTotalBytes = _useState24[0];
117
- var setPageTotalBytes = _useState24[1];
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
- // Close if click is not on the button (the dropdown uses onMouseDown with preventDefault)
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 all method definitions from the current Monaco model
1671
- function parseRubyMethods(model) {
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 DEF_RE = /^\s*def\s+(self\.)?([a-zA-Z_][a-zA-Z0-9_?!=]*)/;
1691
+ var lines = [];
1675
1692
  for (var i = 1; i <= lineCount; i++) {
1676
- var line = model.getLineContent(i);
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 methods;
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
- setMethodsList(model ? parseRubyMethods(model) : []);
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
- methodsList.length === 0
2036
- ? React.createElement('div', { className: 'ide-methods-dropdown-empty' }, 'No methods found')
2037
- : methodsList.map(function(m) {
2038
- return React.createElement(
2039
- 'div',
2040
- {
2041
- key: m.line,
2042
- className: 'ide-methods-dropdown-item',
2043
- onMouseDown: function(e) {
2044
- e.preventDefault();
2045
- setMethodsOpen(false);
2046
- if (monacoRef.current) {
2047
- monacoRef.current.revealLineInCenter(m.line);
2048
- monacoRef.current.setPosition({ lineNumber: m.line, column: 1 });
2049
- monacoRef.current.focus();
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
- React.createElement('span', { className: 'ide-methods-dropdown-line' }, m.line),
2054
- m.name
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
- # Resolves symlinks on the nearest existing ancestor of the target so that
36
- # a symlink inside the workspace cannot escape the sandbox regardless of
37
- # whether the target itself exists yet (create / rename paths).
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
- def initialize(patterns)
6
- @patterns = patterns.map(&:to_s).reject(&:empty?)
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
- @patterns.any? { |pattern| matches?(pattern, relative_path) }
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
@@ -15,7 +15,7 @@ module Mbeditor
15
15
  end
16
16
  end
17
17
 
18
- matcher = ExclusionMatcher.new(Mbeditor.configuration.excluded_paths)
18
+ matcher = ExclusionMatcher.new(Mbeditor.configuration.excluded_paths, root: root)
19
19
  data = traverse(root, root, matcher)
20
20
 
21
21
  MUTEX.synchronize do
@@ -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
- # Resolves symlinks on the nearest existing ancestor of the target so that a
143
- # symlink inside the repo cannot escape it, mirroring
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mbeditor
4
- VERSION = "0.8.1"
4
+ VERSION = "0.9.0"
5
5
  end
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.8.1
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-22 00:00:00.000000000 Z
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